From 7d17a5842f4e50ad9b87ac47fb78e14b4dac9ea1 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:04:04 +0200 Subject: [PATCH 01/98] feat: add Services.Core, config manager, Lua scripting project and test reformatting - add Services.Core services (EventBus, ConfigManager, JobSystem, TimerWheel, MainThreadDispatcher) and default-services registration - add config section registration (RegisterConfigSection, IConfigManagerService, ConfigRegistrationData) and IEventBus changes - add YamlUtils section/runtime-type helpers - add SquidStd.Scripting.Lua project - iterate network sessions surface (Session, SessionManager, ISessionManager, UDP client/server) - reformat and reorganize the test suite --- SquidStd.slnx | 1 + .../Config/RegisterConfigSectionExtension.cs | 6 +- .../Services/RegisterStdServiceExtension.cs | 4 +- .../Interfaces/Services/ISquidStdService.cs | 1 - .../SquidStd.Abstractions.csproj | 2 +- .../Config/IConfigManagerService.cs | 12 +- .../Interfaces/Events/IEventBus.cs | 28 +- src/SquidStd.Core/Yaml/YamlUtils.cs | 30 +- .../Client/SquidStdUdpClient.cs | 152 +- .../Interfaces/Sessions/ISessionManager.cs | 22 +- .../Server/SquidStdUdpServer.cs | 6 +- src/SquidStd.Network/Sessions/Session.cs | 20 +- .../Sessions/SessionManager.cs | 76 +- src/SquidStd.Network/SquidStd.Network.csproj | 4 +- .../Data/PluginContext.cs | 6 +- .../Interfaces/Plugins/ISquidStdPlugin.cs | 6 +- .../SquidStd.Plugin.Abstractions.csproj | 2 +- .../Attributes/LuaFieldAttribute.cs | 13 + .../Scripts/ScriptFunctionAttribute.cs | 29 + .../Scripts/ScriptModuleAttribute.cs | 27 + .../Context/SquidStdScriptJsonContext.cs | 17 + .../Data/Config/LuaEngineConfig.cs | 19 + .../Data/Internal/ScriptModuleData.cs | 23 + .../Data/Internal/ScriptUserData.cs | 12 + .../Data/Luarc/LuarcCompletionConfig.cs | 21 + .../Data/Luarc/LuarcConfig.cs | 46 + .../Data/Luarc/LuarcDiagnosticsConfig.cs | 12 + .../Data/Luarc/LuarcFormatConfig.cs | 15 + .../Data/Luarc/LuarcFormatDefaultConfig.cs | 15 + .../Data/Luarc/LuarcRuntimeConfig.cs | 15 + .../Data/Luarc/LuarcWorkspaceConfig.cs | 15 + .../Data/Scripts/ScriptErrorInfo.cs | 62 + .../Data/Scripts/ScriptExecutionMetrics.cs | 37 + .../Data/Scripts/ScriptResult.cs | 22 + .../Data/Scripts/ScriptResultBuilder.cs | 84 + .../Descriptors/GenericUserDataDescriptor.cs | 60 + .../Extensions/LuaTableReader.cs | 66 + .../Scripts/AddScriptModuleExtension.cs | 69 + .../Extensions/Scripts/TableExtensions.cs | 23 + .../Interfaces/Events/ILuaEventBridge.cs | 37 + .../Scripts/IScriptEngineService.cs | 185 ++ .../Loaders/LuaScriptLoader.cs | 191 ++ .../Modules/EventsModule.cs | 20 + .../Modules/LogModule.cs | 22 + .../Modules/RandomModule.cs | 109 ++ .../Proxies/LuaProxy.cs | 36 + .../Services/LuaEventBridge.cs | 122 ++ .../Services/LuaScriptEngineService.cs | 1555 +++++++++++++++++ .../SquidStd.Scripting.Lua.csproj | 23 + .../Utils/LuaDocumentationGenerator.cs | 1054 +++++++++++ .../RegisterDefaultServicesExtensions.cs | 52 +- .../Services/ConfigManagerService.cs | 9 +- .../Services/EventBusService.cs | 130 +- .../Services/JobSystemService.cs | 44 +- .../Services/TimerWheelService.cs | 2 +- .../SquidStd.Services.Core.csproj | 8 +- .../AddTypedListMethodExtensionTests.cs | 30 +- .../ConfigRegistrationDataTests.cs | 28 +- .../RegisterConfigSectionExtensionTests.cs | 42 +- .../RegisterStdServiceExtensionTests.cs | 44 +- .../ServiceRegistrationDataTests.cs | 24 +- .../Directories/DirectoriesConfigTests.cs | 20 +- .../Directories/DirectoriesExtensionTests.cs | 11 +- .../Extensions/Env/EnvExtensionsTests.cs | 10 +- .../Logger/LogLevelExtensionsTests.cs | 13 +- .../Strings/StringMethodExtensionTests.cs | 28 +- .../Json/JsonContextTypeResolverTests.cs | 34 +- tests/SquidStd.Tests/Json/JsonUtilsTests.cs | 118 +- .../Logging/EventSinkExtensionsTests.cs | 9 +- .../SquidStd.Tests/Logging/EventSinkTests.cs | 70 +- .../Network/CircularBufferTests.cs | 140 +- .../Network/NetMiddlewarePipelineTests.cs | 65 +- .../Network/PacketExtensionsTests.cs | 6 +- .../Network/SessionManagerTests.cs | 198 +-- tests/SquidStd.Tests/Network/SessionTests.cs | 37 +- .../SquidStd.Tests/Network/SpanReaderTests.cs | 96 +- .../SquidStd.Tests/Network/SpanWriterTests.cs | 76 +- .../Network/SquidStdUdpClientTests.cs | 90 +- .../Network/SquidStdUdpServerTests.cs | 56 +- .../PluginAbstractions/PluginContextTests.cs | 18 +- .../PluginAbstractions/PluginMetadataTests.cs | 24 +- .../PluginAbstractions/SquidStdPluginTests.cs | 26 +- .../Core/ConfigManagerServiceTests.cs | 72 +- .../Services/Core/EventBusServiceTests.cs | 179 +- .../Services/Core/JobSystemServiceTests.cs | 29 +- .../Core/MainThreadDispatcherServiceTests.cs | 72 +- .../RegisterDefaultServicesExtensionsTests.cs | 50 +- .../Services/Core/TimerWheelServiceTests.cs | 133 +- tests/SquidStd.Tests/SquidStd.Tests.csproj | 50 +- .../Support/FakeNetworkConnection.cs | 14 +- tests/SquidStd.Tests/Support/FakePlugin.cs | 2 +- tests/SquidStd.Tests/Support/TempDirectory.cs | 2 +- .../SquidStd.Tests/Support/TestJsonContext.cs | 7 +- .../Utils/DirectoriesUtilsTests.cs | 34 +- tests/SquidStd.Tests/Utils/HashUtilsTests.cs | 55 +- .../SquidStd.Tests/Utils/NetworkUtilsTests.cs | 83 +- .../Utils/PlatformUtilsTests.cs | 7 +- .../Utils/ResourceUtilsTests.cs | 63 +- .../SquidStd.Tests/Utils/StringUtilsTests.cs | 89 +- tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs | 76 +- 100 files changed, 5496 insertions(+), 1513 deletions(-) create mode 100644 src/SquidStd.Scripting.Lua/Attributes/LuaFieldAttribute.cs create mode 100644 src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs create mode 100644 src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs create mode 100644 src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs create mode 100644 src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs create mode 100644 src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs create mode 100644 src/SquidStd.Scripting.Lua/Extensions/LuaTableReader.cs create mode 100644 src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs create mode 100644 src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs create mode 100644 src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs create mode 100644 src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs create mode 100644 src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs create mode 100644 src/SquidStd.Scripting.Lua/Modules/EventsModule.cs create mode 100644 src/SquidStd.Scripting.Lua/Modules/LogModule.cs create mode 100644 src/SquidStd.Scripting.Lua/Modules/RandomModule.cs create mode 100644 src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs create mode 100644 src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs create mode 100644 src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs create mode 100644 src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj create mode 100644 src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index 81f362b6..28b5ceed 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -3,6 +3,7 @@ + diff --git a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs index 4479c40d..98326a6f 100644 --- a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs +++ b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs @@ -42,10 +42,8 @@ public static IContainer RegisterConfigSection( } } - var factory = createDefault ?? (() => new TConfig()); - container.AddToRegisterTypedList( - new ConfigRegistrationData(sectionName, configType, () => factory(), priority) - ); + var factory = createDefault ?? (() => new()); + container.AddToRegisterTypedList(new ConfigRegistrationData(sectionName, configType, () => factory(), priority)); return container; } diff --git a/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs b/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs index e2ffdc7b..10cb1f99 100644 --- a/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs +++ b/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs @@ -6,8 +6,7 @@ namespace SquidStd.Abstractions.Extensions.Services; public static class RegisterStdServiceExtension { - - public static IContainer RegisterStdService(this IContainer container, int priority = 0) + public static IContainer RegisterStdService(this IContainer container, int priority = 0) where TService : class where TImplementation : class, TService { @@ -17,5 +16,4 @@ public static IContainer RegisterStdService(this ICont return container; } - } diff --git a/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs b/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs index 3acc621c..4e9b5066 100644 --- a/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs +++ b/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs @@ -14,5 +14,4 @@ public interface ISquidStdService /// Token used to cancel the asynchronous operation. /// A task that represents the asynchronous stop operation. ValueTask StopAsync(CancellationToken cancellationToken = default); - } diff --git a/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj b/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj index 0b7093d9..8e1a1b5d 100644 --- a/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj +++ b/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs index 5acda04c..0ae4a387 100644 --- a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs +++ b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs @@ -25,6 +25,12 @@ public interface IConfigManagerService /// IReadOnlyCollection Entries { get; } + /// + /// Composes the currently loaded sections into YAML. + /// + /// The composed YAML document. + string Compose(); + /// /// Gets a loaded configuration section from DI. /// @@ -32,12 +38,6 @@ public interface IConfigManagerService /// The loaded configuration section. TConfig GetConfig() where TConfig : class; - /// - /// Composes the currently loaded sections into YAML. - /// - /// The composed YAML document. - string Compose(); - /// /// Loads or creates the configured YAML file and registers every section into DI. /// diff --git a/src/SquidStd.Core/Interfaces/Events/IEventBus.cs b/src/SquidStd.Core/Interfaces/Events/IEventBus.cs index f07e08fe..87b076e3 100644 --- a/src/SquidStd.Core/Interfaces/Events/IEventBus.cs +++ b/src/SquidStd.Core/Interfaces/Events/IEventBus.cs @@ -5,20 +5,6 @@ namespace SquidStd.Core.Interfaces.Events; /// public interface IEventBus { - /// - /// Registers a synchronous listener for the specified event type. - /// - /// Listener that handles published events. - /// The event type. - void RegisterListener(ISyncEventListener listener) where TEvent : IEvent; - - /// - /// Registers an asynchronous listener for the specified event type. - /// - /// Listener that handles published events asynchronously. - /// The event type. - void RegisterAsyncListener(IAsyncEventListener listener) where TEvent : IEvent; - /// /// Dispatches an event to synchronous listeners and waits until every listener has completed. /// @@ -34,4 +20,18 @@ public interface IEventBus /// The event type. /// A task that completes after all asynchronous listeners finish. Task PublishAsync(TEvent eventData, CancellationToken cancellationToken) where TEvent : IEvent; + + /// + /// Registers an asynchronous listener for the specified event type. + /// + /// Listener that handles published events asynchronously. + /// The event type. + void RegisterAsyncListener(IAsyncEventListener listener) where TEvent : IEvent; + + /// + /// Registers a synchronous listener for the specified event type. + /// + /// Listener that handles published events. + /// The event type. + void RegisterListener(ISyncEventListener listener) where TEvent : IEvent; } diff --git a/src/SquidStd.Core/Yaml/YamlUtils.cs b/src/SquidStd.Core/Yaml/YamlUtils.cs index 3d9e8b5a..1e8dd7ca 100644 --- a/src/SquidStd.Core/Yaml/YamlUtils.cs +++ b/src/SquidStd.Core/Yaml/YamlUtils.cs @@ -45,6 +45,21 @@ public static object Deserialize(string yaml, Type type) throw new InvalidDataException($"Deserialization returned null for type {type.Name}"); } + /// + /// Deserializes YAML from a file using reflection-based metadata. + /// + /// The YAML file path. + /// The target type. + /// The deserialized object. + public static T DeserializeFromFile(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + var yaml = File.ReadAllText(GetExistingFilePath(filePath)); + + return Deserialize(yaml); + } + /// /// Deserializes a top-level YAML section to the specified runtime type. /// @@ -68,21 +83,6 @@ public static object Deserialize(string yaml, Type type) return Deserialize(Serializer.Serialize(section), type); } - /// - /// Deserializes YAML from a file using reflection-based metadata. - /// - /// The YAML file path. - /// The target type. - /// The deserialized object. - public static T DeserializeFromFile(string filePath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - - var yaml = File.ReadAllText(GetExistingFilePath(filePath)); - - return Deserialize(yaml); - } - /// /// Serializes an object to YAML using reflection-based metadata. /// diff --git a/src/SquidStd.Network/Client/SquidStdUdpClient.cs b/src/SquidStd.Network/Client/SquidStdUdpClient.cs index 65bcf7d6..7c54350a 100644 --- a/src/SquidStd.Network/Client/SquidStdUdpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdUdpClient.cs @@ -91,31 +91,59 @@ public EndPoint? LocalEndPoint /// public SquidStdUdpClient(IPEndPoint? localEndPoint = null, IPEndPoint? defaultRemoteEndPoint = null) { - _udpClient = new UdpClient(localEndPoint ?? new IPEndPoint(IPAddress.Any, 0)); + _udpClient = new(localEndPoint ?? new IPEndPoint(IPAddress.Any, 0)); _defaultRemoteEndPoint = defaultRemoteEndPoint; SessionId = Interlocked.Increment(ref _sessionIdSequence); } /// - /// Starts the receive loop and raises . + /// Closes the client and raises once. /// - public Task StartAsync(CancellationToken cancellationToken) + public async Task CloseAsync(CancellationToken cancellationToken = default) { - if (Interlocked.Exchange(ref _started, 1) != 0) + if (Interlocked.Exchange(ref _closed, 1) != 0) { - return Task.CompletedTask; + return; } - if (cancellationToken.CanBeCanceled) + try { - _externalCancellationTokenRegistration = - cancellationToken.Register(() => _ = CloseAsync(CancellationToken.None)); + await _internalCancellationTokenSource.CancelAsync().WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Once close has started, still tear down the socket below. } - RaiseConnected(); - _receiveLoopTask = Task.Run(ReceiveLoopAsync, CancellationToken.None); + _udpClient.Close(); + _externalCancellationTokenRegistration.Dispose(); + RaiseDisconnected(); + } - return Task.CompletedTask; + /// + public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + { + await CloseAsync(CancellationToken.None); + + // Drain the receive loop before disposing the resources it relies on. + if (_receiveLoopTask is not null) + { + try + { + await _receiveLoopTask; + } + catch + { + // Loop failures are already surfaced via OnException. + } + } + + _internalCancellationTokenSource.Dispose(); + _udpClient.Dispose(); } /// @@ -157,27 +185,51 @@ public async Task SendToAsync(ReadOnlyMemory payload, IPEndPoint endPoint, } /// - /// Closes the client and raises once. + /// Starts the receive loop and raises . /// - public async Task CloseAsync(CancellationToken cancellationToken = default) + public Task StartAsync(CancellationToken cancellationToken) { - if (Interlocked.Exchange(ref _closed, 1) != 0) + if (Interlocked.Exchange(ref _started, 1) != 0) { - return; + return Task.CompletedTask; } - try - { - await _internalCancellationTokenSource.CancelAsync().WaitAsync(cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + if (cancellationToken.CanBeCanceled) { - // Once close has started, still tear down the socket below. + _externalCancellationTokenRegistration = + cancellationToken.Register(() => _ = CloseAsync(CancellationToken.None)); } - _udpClient.Close(); - _externalCancellationTokenRegistration.Dispose(); - RaiseDisconnected(); + RaiseConnected(); + _receiveLoopTask = Task.Run(ReceiveLoopAsync, CancellationToken.None); + + return Task.CompletedTask; + } + + private void RaiseConnected() + { + _logger.Information( + "UDP client started. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", + SessionId, + LocalEndPoint + ); + OnConnected?.Invoke(this, new(this)); + } + + private void RaiseDisconnected() + { + _logger.Information( + "UDP client closed. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", + SessionId, + LocalEndPoint + ); + OnDisconnected?.Invoke(this, new(this)); + } + + private void RaiseException(Exception exception) + { + _logger.Error(exception, "UDP client exception. SessionId={SessionId}", SessionId); + OnException?.Invoke(this, new(exception, this)); } private async Task ReceiveLoopAsync() @@ -214,56 +266,4 @@ private async Task ReceiveLoopAsync() await CloseAsync(CancellationToken.None); } } - - private void RaiseConnected() - { - _logger.Information( - "UDP client started. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", - SessionId, - LocalEndPoint - ); - OnConnected?.Invoke(this, new(this)); - } - - private void RaiseDisconnected() - { - _logger.Information( - "UDP client closed. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", - SessionId, - LocalEndPoint - ); - OnDisconnected?.Invoke(this, new(this)); - } - - private void RaiseException(Exception exception) - { - _logger.Error(exception, "UDP client exception. SessionId={SessionId}", SessionId); - OnException?.Invoke(this, new(exception, this)); - } - - /// - public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - { - await CloseAsync(CancellationToken.None); - - // Drain the receive loop before disposing the resources it relies on. - if (_receiveLoopTask is not null) - { - try - { - await _receiveLoopTask; - } - catch - { - // Loop failures are already surfaced via OnException. - } - } - - _internalCancellationTokenSource.Dispose(); - _udpClient.Dispose(); - } } diff --git a/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs b/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs index 0e638ac2..cc41e88c 100644 --- a/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs +++ b/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs @@ -15,11 +15,14 @@ public interface ISessionManager /// Snapshot of all active sessions. IReadOnlyCollection> Sessions { get; } - /// Looks up a session by its id. - bool TryGetSession(long sessionId, out Session? session); + /// Raised when a new session is created. + event EventHandler>? OnSessionCreated; - /// Sends a payload to a single session. No-op when the id is unknown. - Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default); + /// Raised when a session is removed. + event EventHandler>? OnSessionRemoved; + + /// Raised when a session receives data. + event EventHandler>? OnSessionData; /// Sends a payload to every active session, isolating per-session failures. Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default); @@ -27,12 +30,9 @@ public interface ISessionManager /// Closes a session's connection. No-op when the id is unknown. Task DisconnectAsync(long sessionId, CancellationToken cancellationToken = default); - /// Raised when a new session is created. - event EventHandler>? OnSessionCreated; - - /// Raised when a session is removed. - event EventHandler>? OnSessionRemoved; + /// Sends a payload to a single session. No-op when the id is unknown. + Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default); - /// Raised when a session receives data. - event EventHandler>? OnSessionData; + /// Looks up a session by its id. + bool TryGetSession(long sessionId, out Session? session); } diff --git a/src/SquidStd.Network/Server/SquidStdUdpServer.cs b/src/SquidStd.Network/Server/SquidStdUdpServer.cs index 06b6177c..1bcc41ca 100644 --- a/src/SquidStd.Network/Server/SquidStdUdpServer.cs +++ b/src/SquidStd.Network/Server/SquidStdUdpServer.cs @@ -272,6 +272,10 @@ private IEnumerable ResolveBindEndPoints() return [_endPoint]; } - return [.. NetworkUtils.GetListeningAddresses(_endPoint).Select(address => new IPEndPoint(address.Address, _endPoint.Port))]; + return + [ + .. NetworkUtils.GetListeningAddresses(_endPoint) + .Select(address => new IPEndPoint(address.Address, _endPoint.Port)) + ]; } } diff --git a/src/SquidStd.Network/Sessions/Session.cs b/src/SquidStd.Network/Sessions/Session.cs index d2cb224a..6ff1a269 100644 --- a/src/SquidStd.Network/Sessions/Session.cs +++ b/src/SquidStd.Network/Sessions/Session.cs @@ -9,13 +9,11 @@ namespace SquidStd.Network.Sessions; /// Application-defined per-connection state. public sealed class Session { - private readonly INetworkConnection _connection; - /// Unique connection identifier assigned by the transport. public long SessionId { get; } /// Underlying transport connection. - public INetworkConnection Connection => _connection; + public INetworkConnection Connection { get; } /// Application-defined state for this session. public TState State { get; } @@ -24,26 +22,26 @@ public sealed class Session public DateTimeOffset CreatedAtUtc { get; } /// Remote endpoint of the connection, when available. - public EndPoint? RemoteEndPoint => _connection.RemoteEndPoint; + public EndPoint? RemoteEndPoint => Connection.RemoteEndPoint; /// Whether the underlying connection is still open. - public bool IsConnected => _connection.IsConnected; + public bool IsConnected => Connection.IsConnected; public Session(long sessionId, INetworkConnection connection, TState state, DateTimeOffset createdAtUtc) { ArgumentNullException.ThrowIfNull(connection); - _connection = connection; + Connection = connection; SessionId = sessionId; State = state; CreatedAtUtc = createdAtUtc; } - /// Sends a payload over the underlying connection. - public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => _connection.SendAsync(payload, cancellationToken); - /// Closes the underlying connection. public Task CloseAsync(CancellationToken cancellationToken = default) - => _connection.CloseAsync(cancellationToken); + => Connection.CloseAsync(cancellationToken); + + /// Sends a payload over the underlying connection. + public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => Connection.SendAsync(payload, cancellationToken); } diff --git a/src/SquidStd.Network/Sessions/SessionManager.cs b/src/SquidStd.Network/Sessions/SessionManager.cs index e2daf45c..4f0d4b39 100644 --- a/src/SquidStd.Network/Sessions/SessionManager.cs +++ b/src/SquidStd.Network/Sessions/SessionManager.cs @@ -48,16 +48,6 @@ public SessionManager(SquidTcpServer server, Func st _server.OnDataReceived += HandleServerDataReceived; } - /// - public bool TryGetSession(long sessionId, out Session? session) - => _sessions.TryGetValue(sessionId, out session); - - /// - public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => _sessions.TryGetValue(sessionId, out var session) - ? session.SendAsync(payload, cancellationToken) - : Task.CompletedTask; - /// public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) { @@ -78,6 +68,29 @@ public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken ? session.CloseAsync(cancellationToken) : Task.CompletedTask; + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _server.OnClientConnect -= HandleServerClientConnect; + _server.OnClientDisconnect -= HandleServerClientDisconnect; + _server.OnDataReceived -= HandleServerDataReceived; + _sessions.Clear(); + } + + /// + public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => _sessions.TryGetValue(sessionId, out var session) + ? session.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + + /// + public bool TryGetSession(long sessionId, out Session? session) + => _sessions.TryGetValue(sessionId, out session); + internal void HandleConnected(INetworkConnection connection) { var session = new Session( @@ -113,18 +126,6 @@ internal void HandleDisconnected(INetworkConnection connection) } } - private async Task SendSafelyAsync(Session session, ReadOnlyMemory payload, CancellationToken cancellationToken) - { - try - { - await session.SendAsync(payload, cancellationToken); - } - catch (Exception ex) - { - _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); - } - } - private void HandleServerClientConnect(object? sender, SquidStdTcpClientEventArgs e) => HandleConnected(e.Client); @@ -146,40 +147,43 @@ private void RaiseSessionCreated(Session session) } } - private void RaiseSessionRemoved(Session session) + private void RaiseSessionData(Session session, ReadOnlyMemory data) { try { - OnSessionRemoved?.Invoke(this, new(session)); + OnSessionData?.Invoke(this, new(session, data)); } catch (Exception ex) { - _logger.Error(ex, "OnSessionRemoved handler failed for session {SessionId}", session.SessionId); + _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); } } - private void RaiseSessionData(Session session, ReadOnlyMemory data) + private void RaiseSessionRemoved(Session session) { try { - OnSessionData?.Invoke(this, new(session, data)); + OnSessionRemoved?.Invoke(this, new(session)); } catch (Exception ex) { - _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); + _logger.Error(ex, "OnSessionRemoved handler failed for session {SessionId}", session.SessionId); } } - public void Dispose() + private async Task SendSafelyAsync( + Session session, + ReadOnlyMemory payload, + CancellationToken cancellationToken + ) { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + try { - return; + await session.SendAsync(payload, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); } - - _server.OnClientConnect -= HandleServerClientConnect; - _server.OnClientDisconnect -= HandleServerClientDisconnect; - _server.OnDataReceived -= HandleServerDataReceived; - _sessions.Clear(); } } diff --git a/src/SquidStd.Network/SquidStd.Network.csproj b/src/SquidStd.Network/SquidStd.Network.csproj index dc6e8585..2dc22e59 100644 --- a/src/SquidStd.Network/SquidStd.Network.csproj +++ b/src/SquidStd.Network/SquidStd.Network.csproj @@ -7,11 +7,11 @@ - + - + diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs index 56986223..93fed50e 100644 --- a/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs @@ -2,10 +2,8 @@ namespace SquidStd.Plugin.Abstractions.Data; public class PluginContext { - public Dictionary Data { get; } = new Dictionary(); + public Dictionary Data { get; } = new(); public TData GetData(string key) - { - return (TData)Data[key]; - } + => (TData)Data[key]; } diff --git a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs index 7b575827..d761f5a0 100644 --- a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs +++ b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs @@ -4,7 +4,7 @@ namespace SquidStd.Plugin.Abstractions.Interfaces.Plugins; /// -/// Implemented by trusted .NET plugins loaded by Moongate during server startup. +/// Implemented by trusted .NET plugins loaded by Moongate during server startup. /// public interface ISquidStdPlugin { @@ -12,8 +12,8 @@ public interface ISquidStdPlugin PluginMetadata Metadata { get; } /// - /// Registers the plugin's services, handlers, config sections, Lua modules, and other integrations. - /// Called during container configuration before global server YAML config is loaded. + /// Registers the plugin's services, handlers, config sections, Lua modules, and other integrations. + /// Called during container configuration before global server YAML config is loaded. /// /// The DryIoc container being configured. /// The plugin-specific boot context. diff --git a/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj b/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj index 0b7093d9..8e1a1b5d 100644 --- a/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj +++ b/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/SquidStd.Scripting.Lua/Attributes/LuaFieldAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/LuaFieldAttribute.cs new file mode 100644 index 00000000..3ef67b55 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Attributes/LuaFieldAttribute.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Scripting.Lua.Attributes; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class LuaFieldAttribute : Attribute +{ + public LuaFieldAttribute(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + } + + public string Name { get; } +} diff --git a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs new file mode 100644 index 00000000..5f20e670 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs @@ -0,0 +1,29 @@ +namespace SquidStd.Scripting.Lua.Attributes.Scripts; + +/// +/// Attribute that marks a method as a script function exposed to scripting languages. +/// +[AttributeUsage(AttributeTargets.Method)] +public class ScriptFunctionAttribute : Attribute +{ + /// + /// Gets the optional name override for the script function. + /// + public string? FunctionName { get; } + + /// + /// Gets the optional help text describing the function's purpose. + /// + public string? HelpText { get; } + + /// + /// Initializes a new instance of the ScriptFunctionAttribute class. + /// + /// The optional name override for the script function. + /// The optional help text describing the function's purpose. + public ScriptFunctionAttribute(string? functionName = null, string? helpText = null) + { + FunctionName = functionName; + HelpText = helpText; + } +} diff --git a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs new file mode 100644 index 00000000..0e54b821 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs @@ -0,0 +1,27 @@ +namespace SquidStd.Scripting.Lua.Attributes.Scripts; + +/// +/// Attribute that marks a class as a script module exposed to scripting languages. +/// +[AttributeUsage(AttributeTargets.Class)] +public class ScriptModuleAttribute : Attribute +{ + /// Gets the name under which the module will be accessible in Lua. + public string Name { get; } + + /// Gets the optional help text describing the module's purpose. + public string? HelpText { get; } + + /// + /// Initializes a new instance of the ScriptModuleAttribute class. + /// + /// The name under which the module will be accessible in Lua. + /// The optional help text describing the module's purpose. + public ScriptModuleAttribute(string name, string? helpText = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + HelpText = helpText; + } +} diff --git a/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs new file mode 100644 index 00000000..485076cb --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using SquidStd.Scripting.Lua.Data.Luarc; + +namespace SquidStd.Scripting.Lua.Context; + +[JsonSerializable(typeof(LuarcConfig))] +[JsonSerializable(typeof(LuarcRuntimeConfig))] +[JsonSerializable(typeof(LuarcWorkspaceConfig))] +[JsonSerializable(typeof(LuarcDiagnosticsConfig))] +[JsonSerializable(typeof(LuarcCompletionConfig))] +[JsonSerializable(typeof(LuarcFormatConfig))] +/// +/// JSON serialization context for Lua scripting configuration types. +/// +public partial class SquidStdScriptJsonContext : JsonSerializerContext +{ +} diff --git a/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs new file mode 100644 index 00000000..b65e4924 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs @@ -0,0 +1,19 @@ +namespace SquidStd.Scripting.Lua.Data.Config; + +public sealed record LuaEngineConfig +{ + public string LuarcDirectory { get; } + public string ScriptsDirectory { get; } + public string EngineVersion { get; } + + public LuaEngineConfig(string luarcDirectory, string scriptsDirectory, string engineVersion) + { + ArgumentException.ThrowIfNullOrWhiteSpace(luarcDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(scriptsDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(engineVersion); + + LuarcDirectory = luarcDirectory; + ScriptsDirectory = scriptsDirectory; + EngineVersion = engineVersion; + } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs new file mode 100644 index 00000000..531d682d --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs @@ -0,0 +1,23 @@ +namespace SquidStd.Scripting.Lua.Data.Internal; + +/// +/// Record containing data about a script module for internal processing. +/// +public sealed record ScriptModuleData +{ + /// + /// The .NET type of the script module. + /// + public Type ModuleType { get; } + + /// + /// Initializes a new instance of the ScriptModuleData record. + /// + /// The .NET type of the script module. + public ScriptModuleData(Type moduleType) + { + ArgumentNullException.ThrowIfNull(moduleType); + + ModuleType = moduleType; + } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs new file mode 100644 index 00000000..6f518bbb --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs @@ -0,0 +1,12 @@ +namespace SquidStd.Scripting.Lua.Data.Internal; + +/// +/// Represents user data for scripts. +/// +public class ScriptUserData +{ + /// + /// Gets or sets the user type. + /// + public Type UserType { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs new file mode 100644 index 00000000..b7086634 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Completion configuration for Lua Language Server +/// +public class LuarcCompletionConfig +{ + /// + /// Gets or sets whether completion is enabled. + /// + [JsonPropertyName("enable")] + public bool Enable { get; set; } = true; + + /// + /// Gets or sets the call snippet setting. + /// + [JsonPropertyName("callSnippet")] + public string CallSnippet { get; set; } = "Replace"; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs new file mode 100644 index 00000000..8726bd11 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Configuration class for Lua Language Server (.luarc.json file) +/// +public class LuarcConfig +{ + [JsonPropertyName("$schema")] + + /// + /// + /// + public string Schema { get; set; } = "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json"; + + /// + /// Gets or sets the runtime configuration. + /// + [JsonPropertyName("runtime")] + public LuarcRuntimeConfig Runtime { get; set; } = new(); + + /// + /// Gets or sets the workspace configuration. + /// + [JsonPropertyName("workspace")] + public LuarcWorkspaceConfig Workspace { get; set; } = new(); + + /// + /// Gets or sets the diagnostics configuration. + /// + [JsonPropertyName("diagnostics")] + public LuarcDiagnosticsConfig Diagnostics { get; set; } = new(); + + /// + /// Gets or sets the completion configuration. + /// + [JsonPropertyName("completion")] + public LuarcCompletionConfig Completion { get; set; } = new(); + + /// + /// Gets or sets the format configuration. + /// + [JsonPropertyName("format")] + public LuarcFormatConfig Format { get; set; } = new(); +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs new file mode 100644 index 00000000..bb22e469 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Diagnostics configuration for Lua Language Server +/// +public class LuarcDiagnosticsConfig +{ + [JsonPropertyName("globals")] + public string[] Globals { get; set; } = []; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs new file mode 100644 index 00000000..0828099e --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Format configuration for Lua Language Server +/// +public class LuarcFormatConfig +{ + [JsonPropertyName("enable")] + public bool Enable { get; set; } = true; + + [JsonPropertyName("defaultConfig")] + public LuarcFormatDefaultConfig DefaultConfig { get; set; } = new(); +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs new file mode 100644 index 00000000..e34ca695 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Default format configuration for Lua Language Server +/// +public class LuarcFormatDefaultConfig +{ + [JsonPropertyName("indent_style")] + public string IndentStyle { get; set; } = "space"; + + [JsonPropertyName("indent_size")] + public string IndentSize { get; set; } = "4"; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs new file mode 100644 index 00000000..b6ecbe4c --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Runtime configuration for Lua Language Server +/// +public class LuarcRuntimeConfig +{ + [JsonPropertyName("version")] + public string Version { get; set; } = "Lua 5.4"; + + [JsonPropertyName("path")] + public string[] Path { get; set; } = []; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs new file mode 100644 index 00000000..72ba1948 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Workspace configuration for Lua Language Server +/// +public class LuarcWorkspaceConfig +{ + [JsonPropertyName("library")] + public string[] Library { get; set; } = []; + + [JsonPropertyName("checkThirdParty")] + public bool CheckThirdParty { get; set; } = false; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs new file mode 100644 index 00000000..5b88190f --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs @@ -0,0 +1,62 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Detailed information about a Lua execution error. +/// +public class ScriptErrorInfo +{ + /// + /// Gets or sets the error message. + /// + public string Message { get; set; } = ""; + + /// + /// Gets or sets the stack trace. + /// + public string? StackTrace { get; set; } + + /// + /// Gets or sets the line number. + /// + public int? LineNumber { get; set; } + + /// + /// Gets or sets the column number. + /// + public int? ColumnNumber { get; set; } + + /// + /// Gets or sets the file name. + /// + public string? FileName { get; set; } + + /// + /// Gets or sets the error type. + /// + public string? ErrorType { get; set; } + + /// + /// Gets or sets the source code. + /// + public string? SourceCode { get; set; } + + /// + /// Original source file name when a mapped source is available. + /// + public string? OriginalFileName { get; set; } + + /// + /// Original line number when a mapped source is available. + /// + public int? OriginalLineNumber { get; set; } + + /// + /// Original column number when a mapped source is available. + /// + public int? OriginalColumnNumber { get; set; } + + /// + /// Optional origin label — e.g. the Lua component name when the error came from a lifecycle hook. + /// + public string? Source { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs new file mode 100644 index 00000000..a14d7fd1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Metrics about script execution performance. +/// +public class ScriptExecutionMetrics +{ + /// + /// Gets or sets the execution time in milliseconds. + /// + public long ExecutionTimeMs { get; set; } + + /// + /// Gets or sets the memory used in bytes. + /// + public long MemoryUsedBytes { get; set; } + + /// + /// Gets or sets the number of statements executed. + /// + public int StatementsExecuted { get; set; } + + /// + /// Gets or sets the number of cache hits. + /// + public int CacheHits { get; set; } + + /// + /// Gets or sets the number of cache misses. + /// + public int CacheMisses { get; set; } + + /// + /// Gets or sets the total number of scripts cached. + /// + public int TotalScriptsCached { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs new file mode 100644 index 00000000..a6e991f1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs @@ -0,0 +1,22 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Represents the result of a script execution. +/// +public class ScriptResult +{ + /// + /// Gets or sets a value indicating whether the script execution was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets the message associated with the script result. + /// + public string Message { get; set; } + + /// + /// Gets or sets the data returned by the script execution. + /// + public object? Data { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs new file mode 100644 index 00000000..147af4e1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs @@ -0,0 +1,84 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Builder class for creating ScriptResult instances. +/// +public class ScriptResultBuilder +{ + private object? _data; + private string _message = ""; + private bool _success; + + /// + /// Builds the ScriptResult instance. + /// + public ScriptResult Build() + => new() + { + Success = _success, + Message = _message, + Data = _data + }; + + /// + /// Creates a ScriptResultBuilder initialized for an error result. + /// + public static ScriptResultBuilder CreateError() + => new ScriptResultBuilder().WithSuccess(false); + + /// + /// Creates a ScriptResultBuilder initialized for a successful result. + /// + public static ScriptResultBuilder CreateSuccess() + => new ScriptResultBuilder().WithSuccess(true); + + /// + /// Sets the result as failed. + /// + public ScriptResultBuilder Failure() + { + _success = false; + + return this; + } + + /// + /// Sets the result as successful. + /// + public ScriptResultBuilder Success() + { + _success = true; + + return this; + } + + /// + /// Sets the data of the result. + /// + public ScriptResultBuilder WithData(object? data) + { + _data = data; + + return this; + } + + /// + /// Sets the message of the result. + /// + public ScriptResultBuilder WithMessage(string message) + { + _message = message; + + return this; + } + + /// + /// Sets the success status of the result. + /// + public ScriptResultBuilder WithSuccess(bool success) + { + _success = success; + + return this; + } +} diff --git a/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs b/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs new file mode 100644 index 00000000..245c9db8 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs @@ -0,0 +1,60 @@ +using MoonSharp.Interpreter; +using MoonSharp.Interpreter.Interop; + +namespace SquidStd.Scripting.Lua.Descriptors; + +/// +/// Generic UserData descriptor that adds support for string concatenation and conversion. +/// Implements the __tostring metamethod to allow Lua to convert any userdata type to strings. +/// +public class GenericUserDataDescriptor : StandardUserDataDescriptor +{ + private readonly bool _isXnaType; + + /// + /// Creates a new descriptor for a type with reflection access mode. + /// Automatically detects if the type is an XNA Framework type. + /// + /// The type to describe (can be XNA or any other .NET type). + public GenericUserDataDescriptor(Type type) + : base(type, InteropAccessMode.Reflection) + { + ArgumentNullException.ThrowIfNull(type); + _isXnaType = type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true; + } + + /// + /// Converts the object to its string representation. + /// This is used by MoonSharp when the object is converted to a string in Lua (concatenation, tostring(), etc.). + /// + /// The object to convert to string. + /// + /// For XNA types: Uses ToString() for a readable representation. + /// For other types: Uses ToString() or the type name if ToString() returns null. + /// + /// + /// In Lua, this allows: + /// + /// local vec = Vector2(10, 20) + /// print("Position: " .. vec) -- ✅ Calls AsString(vec) instead of erroring + /// local str = tostring(vec) -- ✅ Works with tostring() + /// + /// + public override string AsString(object obj) + { + if (obj == null) + { + return "null"; + } + + // Use the object's ToString() method + var str = obj.ToString(); + + return string.IsNullOrWhiteSpace(str) + ? + + // Fallback: use the type name if ToString() returns empty/null + $"{Type.Name}({{}})" + : str; + } +} diff --git a/src/SquidStd.Scripting.Lua/Extensions/LuaTableReader.cs b/src/SquidStd.Scripting.Lua/Extensions/LuaTableReader.cs new file mode 100644 index 00000000..50e2b746 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/LuaTableReader.cs @@ -0,0 +1,66 @@ +using MoonSharp.Interpreter; + +namespace SquidStd.Scripting.Lua.Extensions; + +public static class LuaTableReader +{ + public static bool GetBool(Table table, string key, bool defaultValue = false) + { + var value = GetValue(table, key); + + return value.Type == DataType.Boolean ? value.Boolean : defaultValue; + } + + public static TEnum GetEnum(Table table, string key, TEnum defaultValue) + where TEnum : struct, Enum + { + var value = GetValue(table, key); + + if (value.Type == DataType.String && + Enum.TryParse(value.String, true, out TEnum parsedByName)) + { + return parsedByName; + } + + if (value.Type == DataType.Number) + { + var numericValue = (int)value.Number; + + if (Enum.IsDefined(typeof(TEnum), numericValue)) + { + return (TEnum)Enum.ToObject(typeof(TEnum), numericValue); + } + } + + return defaultValue; + } + + public static float GetFloat(Table table, string key, float defaultValue = 0f) + { + var value = GetValue(table, key); + + return value.Type == DataType.Number ? (float)value.Number : defaultValue; + } + + public static int GetInt(Table table, string key, int defaultValue = 0) + { + var value = GetValue(table, key); + + return value.Type == DataType.Number ? (int)value.Number : defaultValue; + } + + public static string GetString(Table table, string key, string defaultValue = "") + { + var value = GetValue(table, key); + + return value.Type == DataType.String ? value.String : defaultValue; + } + + private static DynValue GetValue(Table table, string key) + { + ArgumentNullException.ThrowIfNull(table); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + return table.Get(key); + } +} diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs new file mode 100644 index 00000000..4cf0e5f0 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs @@ -0,0 +1,69 @@ +using DryIoc; +using MoonSharp.Interpreter; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Scripting.Lua.Data.Internal; + +namespace SquidStd.Scripting.Lua.Extensions.Scripts; + +/// +/// Extension methods for registering Lua script modules in the dependency injection container. +/// +public static class AddScriptModuleExtension +{ + /// The dependency injection container. + extension(IContainer container) + { + /// + /// Registers a user data type with the container for Lua scripting. + /// + public IContainer RegisterLuaUserData(Type userDataType) + { + if (userDataType == null) + { + throw new ArgumentNullException(nameof(userDataType), "User data type cannot be null."); + } + + container.AddToRegisterTypedList(new ScriptUserData { UserType = userDataType }); + + return container; + } + + /// + /// Registers a user data type with the container for Lua scripting using generics. + /// + public IContainer RegisterLuaUserData() + { + UserData.RegisterType(); + + return container.RegisterLuaUserData(typeof(TUserData)); + } + + /// + /// Registers a Lua script module type with the container. + /// + /// The type of the script module to register. + /// The container for method chaining. + /// Thrown when scriptModule is null. + public IContainer RegisterScriptModule(Type scriptModule) + { + if (scriptModule == null) + { + throw new ArgumentNullException(nameof(scriptModule), "Script module type cannot be null."); + } + + container.AddToRegisterTypedList(new ScriptModuleData(scriptModule)); + + container.Register(scriptModule, Reuse.Singleton); + + return container; + } + + /// + /// Registers a Lua script module type with the container using a generic type parameter. + /// + /// The type of the script module to register. + /// The container for method chaining. + public IContainer RegisterScriptModule() where TScriptModule : class + => container.RegisterScriptModule(typeof(TScriptModule)); + } +} diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs new file mode 100644 index 00000000..7b36a728 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Proxies; + +namespace SquidStd.Scripting.Lua.Extensions.Scripts; + +/// +/// Provides extension methods for MoonSharp Table objects to enable proxying to interfaces. +/// +public static class TableExtensions +{ + /// + /// Converts a MoonSharp Table to a proxy implementing the specified interface. + /// + public static TInterface ToProxy(this Table table) + where TInterface : class + { + var proxy = DispatchProxy.Create>(); + ((LuaProxy)(object)proxy).Table = table; + + return proxy; + } +} diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs new file mode 100644 index 00000000..d7e37dbb --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs @@ -0,0 +1,37 @@ +using MoonSharp.Interpreter; + +namespace SquidStd.Scripting.Lua.Interfaces.Events; + +/// +/// Bridges named server events and internal callbacks into Lua closures. +/// +public interface ILuaEventBridge +{ + /// + /// Attaches the active MoonSharp script runtime used to invoke registered closures. + /// + /// Active Lua script runtime. + void Attach(Script script); + + /// + /// Invokes a single closure with the supplied payload. + /// + /// Lua closure to invoke. + /// Payload exposed to Lua as a table. + /// The Lua callback result. + DynValue Invoke(Closure callback, IReadOnlyDictionary payload); + + /// + /// Publishes a named event to every Lua callback registered for it. + /// + /// Stable event name, such as player.connected. + /// Payload exposed to Lua as a table. + void Publish(string eventName, IReadOnlyDictionary payload); + + /// + /// Registers a Lua callback for a named event. + /// + /// Stable event name, such as player.connected. + /// Lua closure to invoke when the event is published. + void Register(string eventName, Closure callback); +} diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs new file mode 100644 index 00000000..957a0fc5 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs @@ -0,0 +1,185 @@ +using SquidStd.Scripting.Lua.Data.Scripts; + +namespace SquidStd.Scripting.Lua.Interfaces.Scripts; + +/// +/// Interface for the script engine service that manages Lua execution. +/// +public interface IScriptEngineService +{ + /// + /// Delegate for handling script file change events. + /// + /// The path to the changed file. + /// True if the file change was handled successfully, false otherwise. + delegate bool LuaFileChangedHandler(string filePath); + + /// + /// Event raised when a script file is modified. + /// + event LuaFileChangedHandler? FileChanged; + + /// + /// Event raised when a script error occurs + /// + event EventHandler? OnScriptError; + + /// + /// Fires once during , after script modules have been registered + /// but before bootstrap scripts run. Handlers can install additional UserData types, globals, + /// or scanners that depend on the script runtime being ready. The argument is the underlying + /// MoonSharp Script, typed as so the interface stays + /// implementation-agnostic; callers cast as needed. + /// + event Action? AfterModulesRegistered; + + /// + /// Fires when a .lua file under a components/ subdirectory of the scripts + /// directory changes on disk. Carries the full file path. Used by the engine-side + /// component loader to hot-reload Lua-defined components. + /// + event Action? OnComponentFileChanged; + + /// + /// Adds a callback function that can be called from Lua. + /// + /// The name of the callback function in Lua. + /// The C# action to execute when the callback is invoked. + void AddCallback(string name, Action callback); + + /// + /// Adds a constant value accessible from Lua. + /// + /// The name of the constant in Lua. + /// The value of the constant. + void AddConstant(string name, object value); + + /// + /// Adds a script to be executed during engine initialization. + /// + /// The Lua code to execute on startup. + void AddInitScript(string script); + + /// + /// Adds a manual module function that can be called from scripts. + /// + /// The name of the module. + /// The name of the function. + /// The callback to execute when the function is called. + void AddManualModuleFunction(string moduleName, string functionName, Action callback); + + /// + /// Adds a typed manual module function that can be called from scripts. + /// + /// The input parameter type. + /// The output return type. + /// The name of the module. + /// The name of the function. + /// The callback function to execute. + void AddManualModuleFunction( + string moduleName, + string functionName, + Func callback + ); + + /// + /// Adds a .NET type as a module accessible from Lua. + /// + /// The type to register as a script module. + void AddScriptModule(Type type); + + /// + /// Adds a directory to the Lua script search paths. + /// + /// Directory path to search for scripts. + void AddSearchDirectory(string path); + + /// + /// Clears the script cache + /// + void ClearScriptCache(); + + /// + /// Executes a previously registered callback function. + /// + /// The name of the callback to execute. + /// Arguments to pass to the callback. + void ExecuteCallback(string name, params object[] args); + + /// + /// Notifies the script engine that the engine initialization is complete and ready. + /// + void ExecuteEngineReady(); + + /// + /// Executes a Lua function or expression and returns the result. + /// + /// The Lua function call or expression to execute. + /// A ScriptResult containing the execution outcome. + ScriptResult ExecuteFunction(string command); + + /// + /// Asynchronously executes a Lua function or expression and returns the result. + /// + /// The Lua function call or expression to execute. + /// A task containing a ScriptResult with the execution outcome. + Task ExecuteFunctionAsync(string command); + + /// + /// Executes a function defined in the bootstrap script. + /// + /// + void ExecuteFunctionFromBootstrap(string name); + + /// + /// Executes a Lua script string. + /// + /// The Lua code to execute. + void ExecuteScript(string script); + + /// + /// Executes a Lua file. + /// + /// The path to the Lua file to execute. + void ExecuteScriptFile(string scriptFile); + + /// + /// Gets execution metrics for performance monitoring + /// + /// Metrics about script execution + ScriptExecutionMetrics GetExecutionMetrics(); + + /// + /// Registers a global object/value accessible from scripts. + /// + /// The name of the global in scripts. + /// The object/value to register. + void RegisterGlobal(string name, object value); + + /// + /// Registers a global function that can be called from scripts. + /// + /// The name of the global function in scripts. + /// The delegate to register as a global function. + void RegisterGlobalFunction(string name, Delegate func); + + /// + /// Starts the script engine service asynchronously. + /// + /// + Task StartAsync(); + + /// + /// Converts a .NET method name to a Lua-compatible function name. + /// + /// The .NET method name to convert. + /// The Lua-compatible function name. + string ToScriptEngineFunctionName(string name); + + /// + /// Unregisters a global function or value. + /// + /// The name of the global to unregister. + /// True if the global was found and removed, false otherwise. + bool UnregisterGlobal(string name); +} diff --git a/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs b/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs new file mode 100644 index 00000000..f4571e88 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs @@ -0,0 +1,191 @@ +using MoonSharp.Interpreter; +using MoonSharp.Interpreter.Loaders; +using Serilog; +using SquidStd.Core.Extensions.Directories; + +namespace SquidStd.Scripting.Lua.Loaders; + +/// +/// Custom script loader for MoonSharp that loads Lua modules from the configured Scripts directory. +/// Implements the MoonSharp script loader interface to provide require() functionality. +/// +public class LuaScriptLoader : ScriptLoaderBase +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly List _scriptsDirectories; + + /// + /// Initializes a new instance of the LuaScriptLoader class. + /// + /// The directories configuration to resolve the scripts directory. + public LuaScriptLoader(string rootDirectory) + { + ArgumentNullException.ThrowIfNull(rootDirectory); + + _scriptsDirectories = new() { Path.GetFullPath(rootDirectory) }; + + // Configure default module search paths + ModulePaths = + [ + "?.lua", + "?/init.lua", + "modules/?.lua", + "modules/?/init.lua" + ]; + + _logger.Debug("Lua script loader initialized with scripts directories: {ScriptsDirectories}", _scriptsDirectories); + } + + /// + /// Initializes a new instance of the LuaScriptLoader class with multiple search directories. + /// + /// Ordered list of directories to search. + public LuaScriptLoader(IReadOnlyList searchDirectories) + : this(searchDirectories, true) { } + + private LuaScriptLoader(IReadOnlyList searchDirectories, bool _) + { + ArgumentNullException.ThrowIfNull(searchDirectories); + + if (searchDirectories.Count == 0) + { + throw new ArgumentException("Search directories cannot be empty.", nameof(searchDirectories)); + } + + _scriptsDirectories = searchDirectories.Where(d => !string.IsNullOrWhiteSpace(d)) + .Select(d => Path.GetFullPath(d.ResolvePathAndEnvs()).ResolvePathAndEnvs()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (_scriptsDirectories.Count == 0) + { + throw new ArgumentException("Search directories cannot be empty.", nameof(searchDirectories)); + } + + ModulePaths = + [ + "?.lua", + "?/init.lua", + "modules/?.lua", + "modules/?/init.lua" + ]; + + _logger.Debug("Lua script loader initialized with scripts directories: {ScriptsDirectories}", _scriptsDirectories); + } + + public void AddSearchDirectory(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return; + } + + var fullPath = Path.GetFullPath(directory); + + if (_scriptsDirectories.Contains(fullPath, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + _scriptsDirectories.Add(fullPath); + _logger.Debug("Lua script loader added scripts directory: {ScriptsDirectory}", fullPath); + } + + /// + /// Loads a Lua script file from the configured scripts directory. + /// + /// The filename or module name to load. + /// The global context table. + /// The script content as a string, or null if the file doesn't exist. + public override object LoadFile(string file, Table globalContext) + { + ArgumentException.ThrowIfNullOrWhiteSpace(file); + + // Remove .lua extension if present (MoonSharp sometimes adds it) + // This matches the behavior of ScriptFileExists + file = file.Replace(".lua", ""); + + var resolvedPath = ResolveModulePath(file); + + if (resolvedPath == null) + { + _logger.Warning("Script file not found: {FileName}", file); + + return null; + } + + try + { + var content = File.ReadAllText(resolvedPath); + _logger.Debug("Loaded script file: {FileName} from {Path}", file, resolvedPath); + + return content; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to load script file: {FileName}", file); + + throw new ScriptRuntimeException($"Failed to load module '{file}': {ex.Message}"); + } + } + + /// + /// Checks if a script file exists in the configured scripts directory. + /// + /// The filename or module name to check. + /// True if the file exists, false otherwise. + public override bool ScriptFileExists(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + name = name.Replace(".lua", ""); + var resolvedPath = ResolveModulePath(name); + + return resolvedPath != null; + } + + /// + /// Resolves a module name to a full file path by searching through configured module paths. + /// + /// The module name to resolve. + /// The full path to the module file, or null if not found. + private string? ResolveModulePath(string moduleName) + { + // Try each module path pattern + foreach (var searchDirectory in _scriptsDirectories) + { + foreach (var pattern in ModulePaths) + { + var fileName = pattern.Replace("?", moduleName); + var fullPath = Path.Combine(searchDirectory, fileName); + + if (File.Exists(fullPath)) + { + _logger.Debug( + "Resolved module '{ModuleName}' to path: {FullPath}", + moduleName, + fullPath + ); + + return fullPath; + } + } + + // If no pattern matched, try the direct path + var directPath = Path.Combine(searchDirectory, moduleName); + + if (File.Exists(directPath)) + { + _logger.Debug( + "Resolved module '{ModuleName}' to direct path: {DirectPath}", + moduleName, + directPath + ); + + return directPath; + } + } + + return null; + } +} diff --git a/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs new file mode 100644 index 00000000..9137a3e8 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs @@ -0,0 +1,20 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Interfaces.Events; + +namespace SquidStd.Scripting.Lua.Modules; + +[ScriptModule("events", "Allows Lua scripts to subscribe to named server events.")] +public sealed class EventsModule +{ + private readonly ILuaEventBridge _events; + + public EventsModule(ILuaEventBridge events) + { + _events = events; + } + + [ScriptFunction("on", "Registers a callback for a named server event.")] + public void On(string eventName, Closure callback) + => _events.Register(eventName, callback); +} diff --git a/src/SquidStd.Scripting.Lua/Modules/LogModule.cs b/src/SquidStd.Scripting.Lua/Modules/LogModule.cs new file mode 100644 index 00000000..dd462dbc --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Modules/LogModule.cs @@ -0,0 +1,22 @@ +using Serilog; +using SquidStd.Scripting.Lua.Attributes.Scripts; + +namespace SquidStd.Scripting.Lua.Modules; + +[ScriptModule("log", "Provides logging functionalities to scripts.")] +public class LogModule +{ + private readonly ILogger _logger = Log.ForContext(); + + [ScriptFunction(helpText: "Logs a message at the ERROR level.")] + public void Error(string message, params object[]? args) + => _logger.Error(message, args); + + [ScriptFunction(helpText: "Logs a message at the INFO level.")] + public void Info(string message, params object[]? args) + => _logger.Information(message, args); + + [ScriptFunction(helpText: "Logs a message at the WARNING level.")] + public void Warning(string message, params object[]? args) + => _logger.Warning(message, args); +} diff --git a/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs b/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs new file mode 100644 index 00000000..4547ab25 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs @@ -0,0 +1,109 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Attributes.Scripts; + +namespace SquidStd.Scripting.Lua.Modules; + +[ScriptModule("random", "Provides safe random helpers to Lua scripts.")] +public sealed class RandomModule +{ + [ScriptFunction("chance", "Returns true when a random roll is within the given percentage.")] + public bool Chance(double percent) + { + if (percent <= 0) + { + return false; + } + + if (percent >= 100) + { + return true; + } + + return Random.Shared.NextDouble() * 100 < percent; + } + + [ScriptFunction("float", "Returns a random floating-point number between 0 and 1.")] + public double Float() + => Random.Shared.NextDouble(); + + [ScriptFunction("int", "Returns a random integer in the inclusive range.")] + public int Int(int min, int max) + { + if (max < min) + { + throw new ArgumentOutOfRangeException(nameof(max), "Max must be greater than or equal to min."); + } + + if (max == int.MaxValue) + { + return Random.Shared.Next(min, max) + Random.Shared.Next(0, 2); + } + + return Random.Shared.Next(min, max + 1); + } + + [ScriptFunction("pick", "Returns one random value from a Lua array table.")] + public DynValue Pick(Table values) + { + ArgumentNullException.ThrowIfNull(values); + + var length = values.Length; + + if (length <= 0) + { + throw new ArgumentException("Values table cannot be empty.", nameof(values)); + } + + return values.Get(Random.Shared.Next(1, length + 1)); + } + + [ScriptFunction("weighted", "Returns one value from weighted Lua entries.")] + public DynValue Weighted(Table entries) + { + ArgumentNullException.ThrowIfNull(entries); + + var length = entries.Length; + var total = 0d; + + for (var i = 1; i <= length; i++) + { + var entry = entries.Get(i).Table; + total += ReadWeight(entry); + } + + if (total <= 0) + { + throw new ArgumentException("At least one weighted entry must have a positive weight.", nameof(entries)); + } + + var roll = Random.Shared.NextDouble() * total; + var cursor = 0d; + + for (var i = 1; i <= length; i++) + { + var entry = entries.Get(i).Table; + cursor += ReadWeight(entry); + + if (roll <= cursor) + { + return entry.Get("value"); + } + } + + return entries.Get(length).Table.Get("value"); + } + + private static double ReadWeight(Table entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var weight = entry.Get("weight"); + + if (weight.Type != DataType.Number) + { + throw new ArgumentException("Weighted entries must contain a numeric weight."); + } + + return Math.Max(0, weight.Number); + } +} diff --git a/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs b/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs new file mode 100644 index 00000000..bf5c8bd1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using MoonSharp.Interpreter; + +namespace SquidStd.Scripting.Lua.Proxies; + +/// +/// A proxy class that implements an interface by delegating method calls to a MoonSharp Table. +/// +/// The interface type to implement. +public class LuaProxy : DispatchProxy +{ + public Table Table { get; set; } + + /// + /// Invokes the Lua function corresponding to the method name on the associated table. + /// + /// The method information for the invoked method. + /// The arguments passed to the method. + /// The result of the Lua function call, converted to the appropriate type. + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + var fn = Table.Get(targetMethod.Name); + + if (fn.Type != DataType.Function) + { + throw new MissingMethodException(targetMethod.Name); + } + + var dynArgs = args + .Select(a => DynValue.FromObject(null, a)) + .ToArray(); + var result = fn.Function.Call(dynArgs); + + return result.ToObject(targetMethod.ReturnType); + } +} diff --git a/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs new file mode 100644 index 00000000..fe473fb3 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs @@ -0,0 +1,122 @@ +using System.Collections.Concurrent; +using MoonSharp.Interpreter; +using Serilog; +using SquidStd.Scripting.Lua.Interfaces.Events; + +namespace SquidStd.Scripting.Lua.Services; + +/// +/// Default Lua event bridge backed by named MoonSharp closures. +/// +public sealed class LuaEventBridge : ILuaEventBridge +{ + private readonly ConcurrentDictionary> _callbacks = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger = Log.ForContext(); + + private Script? _script; + + public void Attach(Script script) + { + ArgumentNullException.ThrowIfNull(script); + + _script = script; + } + + public DynValue Invoke(Closure callback, IReadOnlyDictionary payload) + { + ArgumentNullException.ThrowIfNull(callback); + ArgumentNullException.ThrowIfNull(payload); + + var script = _script ?? throw new InvalidOperationException("Lua event bridge is not attached to a script."); + var table = CreatePayloadTable(script, payload); + + return script.Call(callback, table); + } + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + ArgumentNullException.ThrowIfNull(payload); + + if (!_callbacks.TryGetValue(eventName, out var callbacks)) + { + return; + } + + Closure[] snapshot; + + lock (callbacks) + { + snapshot = callbacks.ToArray(); + } + + for (var i = 0; i < snapshot.Length; i++) + { + try + { + Invoke(snapshot[i], payload); + } + catch (Exception ex) + { + _logger.Error(ex, "Lua event callback failed for {EventName}", eventName); + } + } + } + + public void Register(string eventName, Closure callback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + ArgumentNullException.ThrowIfNull(callback); + + var callbacks = _callbacks.GetOrAdd(eventName, static _ => []); + + lock (callbacks) + { + callbacks.Add(callback); + } + } + + private static DynValue ConvertValue(Script script, object? value) + { + if (value is null) + { + return DynValue.Nil; + } + + if (value is IReadOnlyDictionary dictionary) + { + return DynValue.NewTable(CreatePayloadTable(script, dictionary)); + } + + if (value is IReadOnlyList list) + { + return DynValue.NewTable(CreateArrayTable(script, list)); + } + + return DynValue.FromObject(script, value); + } + + private static Table CreateArrayTable(Script script, IReadOnlyList values) + { + var table = new Table(script); + + for (var i = 0; i < values.Count; i++) + { + table[i + 1] = ConvertValue(script, values[i]); + } + + return table; + } + + private static Table CreatePayloadTable(Script script, IReadOnlyDictionary payload) + { + var table = new Table(script); + + foreach (var (key, value) in payload) + { + table[key] = ConvertValue(script, value); + } + + return table; + } +} diff --git a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs new file mode 100644 index 00000000..7526b3d1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs @@ -0,0 +1,1555 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using DryIoc; +using MoonSharp.Interpreter; +using Serilog; +using SquidStd.Core.Directories; +using SquidStd.Core.Extensions.Strings; +using SquidStd.Core.Json; +using SquidStd.Core.Utils; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Context; +using SquidStd.Scripting.Lua.Data.Config; +using SquidStd.Scripting.Lua.Data.Internal; +using SquidStd.Scripting.Lua.Data.Luarc; +using SquidStd.Scripting.Lua.Data.Scripts; +using SquidStd.Scripting.Lua.Interfaces.Events; +using SquidStd.Scripting.Lua.Interfaces.Scripts; +using SquidStd.Scripting.Lua.Loaders; +using SquidStd.Scripting.Lua.Utils; + +#pragma warning disable IL2026 // RequiresUnreferencedCode - Lua scripting uses reflection for dynamic functionality +#pragma warning disable IL2072 // DynamicallyAccessedMemberTypes - Reflection access is necessary for scripting + +namespace SquidStd.Scripting.Lua.Services; + +/// +/// Lua engine service that integrates MoonSharp with the SquidCraft game engine +/// Provides script execution, module loading, and Lua meta file generation +/// +public class LuaScriptEngineService : IScriptEngineService, IDisposable +{ + private static readonly string[] _completionExcludedGlobals = ["delay", "toString"]; + + private readonly LuaEngineConfig _engineConfig; + + public event IScriptEngineService.LuaFileChangedHandler? FileChanged; + + private const string OnReadyFunctionName = "on_ready"; + + private const string OnEngineRunFunctionName = "on_initialize"; + + // Thread-safe collections + private readonly ConcurrentDictionary> _callbacks = new(); + private readonly ConcurrentDictionary _constants = new(); + private readonly ConcurrentDictionary> _manualModuleFunctions = new(); + + private readonly DirectoriesConfig _directoriesConfig; + private readonly List _initScripts; + private readonly ConcurrentDictionary _loadedModules = new(); + private readonly ILogger _logger = Log.ForContext(); + + // Script caching - using hash to avoid re-parsing identical scripts + private readonly ConcurrentDictionary _scriptCache = new(); + private readonly List _scriptModules; + private readonly List _loadedUserData; + + private readonly IContainer _serviceProvider; + private int _cacheHits; + private int _cacheMisses; + + private bool _disposed; + private bool _isInitialized; + private Func _nameResolver; + private LuaScriptLoader _scriptLoader; + + private FileSystemWatcher? _watcher; + + /// + /// Initializes a new instance of the LuaScriptEngineService class. + /// + /// The directories configuration. + /// The list of script modules. + /// The list of loaded user data. + /// The service provider. + /// The version service. + public LuaScriptEngineService( + DirectoriesConfig directoriesConfig, + IContainer serviceProvider, + LuaEngineConfig engineConfig, + List scriptModules = null, + List loadedUserData = null + ) + { + JsonUtils.RegisterJsonContext(SquidStdScriptJsonContext.Default); + + scriptModules ??= new(); + loadedUserData ??= new(); + + ArgumentNullException.ThrowIfNull(directoriesConfig); + ArgumentNullException.ThrowIfNull(serviceProvider); + + _scriptModules = scriptModules; + _directoriesConfig = directoriesConfig; + _serviceProvider = serviceProvider; + _engineConfig = engineConfig; + _loadedUserData = loadedUserData ?? new(); + _initScripts = ["bootstrap.lua", "init.lua", "main.lua"]; + + CreateNameResolver(); + + LuaScript = CreateOptimizedEngine(); + + LoadToUserData(); + } + + /// + /// Gets the MoonSharp script instance. + /// + public Script LuaScript { get; } + + /// + /// Event raised when a script error occurs + /// + public event EventHandler? OnScriptError; + + /// + public event Action? AfterModulesRegistered; + + /// + public event Action? OnComponentFileChanged; + + /// + /// Gets the script engine instance. + /// + public object Engine => LuaScript; + + /// + /// Adds a callback function that can be called from Lua scripts. + /// + /// The name of the callback. + /// The callback action. + public void AddCallback(string name, Action callback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(callback); + + var normalizedName = name.ToSnakeCaseUpper(); + _callbacks[normalizedName] = callback; + + _logger.Debug("Callback registered: {Name}", normalizedName); + } + + /// + /// Adds a constant value that can be accessed from Lua scripts. + /// + /// The name of the constant. + /// The value of the constant. + public void AddConstant(string name, object? value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var normalizedName = name.ToSnakeCaseUpper(); + + if (_constants.ContainsKey(normalizedName)) + { + _logger.Warning("Constant {Name} already exists, overwriting", normalizedName); + } + + _constants[normalizedName] = value; + + var valueToSet = value; + + if (value != null && !IsSimpleType(value.GetType())) + { + valueToSet = ObjectToTable(value); + } + + LuaScript.Globals[normalizedName] = valueToSet; + + _logger.Debug("Constant added: {Name}", normalizedName); + } + + /// + /// Adds an initialization script. + /// + /// The script to add. + public void AddInitScript(string script) + { + if (string.IsNullOrWhiteSpace(script)) + { + throw new ArgumentException("Script cannot be null or empty", nameof(script)); + } + + _initScripts.Add(script); + } + + /// + /// Adds a manual module function that can be called from Lua scripts with a callback. + /// + public void AddManualModuleFunction(string moduleName, string functionName, Action callback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + ArgumentException.ThrowIfNullOrWhiteSpace(functionName); + ArgumentNullException.ThrowIfNull(callback); + + var (normalizedModule, normalizedFunction, moduleTable) = PrepareManualModule(moduleName, functionName); + + moduleTable[normalizedFunction] = DynValue.NewCallback( + (_, args) => + { + try + { + var parameters = ConvertArgumentsToArray(args); + callback(parameters); + + return DynValue.Nil; + } + catch (Exception ex) + { + _logger.Error( + ex, + "Error executing manual module action {FunctionName} in {ModuleName}", + normalizedFunction, + normalizedModule + ); + + throw new ScriptRuntimeException(ex.Message); + } + } + ); + + RegisterManualModuleFunction(normalizedModule, normalizedFunction); + } + + /// + /// Adds a manual module function with typed input and output that can be called from Lua scripts. + /// + public void AddManualModuleFunction( + string moduleName, + string functionName, + Func callback + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + ArgumentException.ThrowIfNullOrWhiteSpace(functionName); + ArgumentNullException.ThrowIfNull(callback); + + var (normalizedModule, normalizedFunction, moduleTable) = PrepareManualModule(moduleName, functionName); + + moduleTable[normalizedFunction] = DynValue.NewCallback( + (_, args) => + { + try + { + var input = PrepareManualInput(args); + var result = callback(input); + + return ConvertToLua(result); + } + catch (Exception ex) + { + _logger.Error( + ex, + "Error executing manual module function {FunctionName} in {ModuleName}", + normalizedFunction, + normalizedModule + ); + + throw new ScriptRuntimeException(ex.Message); + } + } + ); + + RegisterManualModuleFunction(normalizedModule, normalizedFunction); + } + + /// + /// Adds a script module to the engine. + /// + /// The type of the script module. + public void AddScriptModule(Type type) + { + ArgumentNullException.ThrowIfNull(type); + _scriptModules.Add(new(type)); + } + + public void AddSearchDirectory(string path) + => _scriptLoader.AddSearchDirectory(path); + + /// + /// Clears the script cache + /// + public void ClearScriptCache() + { + _scriptCache.Clear(); + _cacheHits = 0; + _cacheMisses = 0; + _logger.Information("Script cache cleared"); + } + + /// + /// Disposes of the resources used by the LuaScriptEngineService. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + _loadedModules.Clear(); + _callbacks.Clear(); + _constants.Clear(); + + GC.SuppressFinalize(this); + + _logger.Debug("Lua engine disposed successfully"); + } + catch (Exception ex) + { + _logger.Warning(ex, "Error during Lua engine disposal"); + } + finally + { + _disposed = true; + } + } + + /// + /// Executes a registered callback with the specified arguments. + /// + /// The name of the callback. + /// The arguments to pass to the callback. + public void ExecuteCallback(string name, params object[] args) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var normalizedName = name.ToSnakeCaseUpper(); + + if (_callbacks.TryGetValue(normalizedName, out var callback)) + { + try + { + _logger.Debug("Executing callback {Name}", normalizedName); + callback(args); + } + catch (Exception ex) + { + _logger.Error(ex, "Error executing callback {Name}", normalizedName); + + throw; + } + } + else + { + _logger.Warning("Callback {Name} not found", normalizedName); + } + } + + /// + /// Executes the engine ready function from bootstrap scripts. + /// + public void ExecuteEngineReady() + => ExecuteFunctionFromBootstrap(OnEngineRunFunctionName); + + /// + /// Executes a Lua function and returns the result. + /// + /// The function command to execute. + /// The result of the function execution. + public ScriptResult ExecuteFunction(string command) + { + try + { + var result = LuaScript.DoString($"return {command}"); + + return ScriptResultBuilder.CreateSuccess().WithData(result.ToObject()).Build(); + } + catch (ScriptRuntimeException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, command); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua error at line {Line}, column {Column}: {Message}", + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + return ScriptResultBuilder.CreateError() + .WithMessage( + $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" + ) + .Build(); + } + catch (InterpreterException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, command); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua {ErrorType} at line {Line}, column {Column}: {Message}", + errorInfo.ErrorType, + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + return ScriptResultBuilder.CreateError() + .WithMessage( + $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" + ) + .Build(); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to execute function: {Command}", command); + + return ScriptResultBuilder.CreateError().WithMessage(ex.Message).Build(); + } + } + + /// + /// Executes a Lua function asynchronously and returns the result. + /// + /// The function command to execute. + /// A task representing the asynchronous operation. + public async Task ExecuteFunctionAsync(string command) + => ExecuteFunction(command); + + public void ExecuteFunctionFromBootstrap(string name) + { + try + { + var onReadyFunc = LuaScript.Globals.Get(name); + + if (onReadyFunc.Type == DataType.Nil) + { + _logger.Warning("No {FuncName} function defined in scripts", name); + + return; + } + + // Verify it's actually a function before calling + if (onReadyFunc.Type != DataType.Function) + { + _logger.Error( + "'{FuncName}' is defined but is not a function, it's a {Type}. Skipping execution.", + name, + onReadyFunc.Type + ); + + return; + } + + LuaScript.Call(onReadyFunc); + _logger.Debug("Boot function executed successfully"); + } + catch (Exception ex) + { + _logger.Error(ex, "Error executing onReady function"); + + throw; + } + } + + /// + /// Executes a script string. + /// + /// The script to execute. + public void ExecuteScript(string script) + => ExecuteScript(script, null); + + /// + /// Executes a script from a file. + /// + /// The path to the script file. + public void ExecuteScriptFile(string scriptFile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scriptFile); + + if (!File.Exists(scriptFile)) + { + throw new FileNotFoundException($"Script file not found: {scriptFile}", scriptFile); + } + + try + { + var content = File.ReadAllText(scriptFile); + _logger.Debug("Executing script file: {FileName}", Path.GetFileName(scriptFile)); + ExecuteScript(content, scriptFile); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to execute script file: {FileName}", Path.GetFileName(scriptFile)); + } + } + + /// + /// Executes a script file asynchronously. + /// + /// The path to the script file. + /// A task representing the asynchronous operation. + public async Task ExecuteScriptFileAsync(string scriptFile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scriptFile); + + if (!File.Exists(scriptFile)) + { + throw new FileNotFoundException($"Script file not found: {scriptFile}", scriptFile); + } + + try + { + var content = await File.ReadAllTextAsync(scriptFile).ConfigureAwait(false); + _logger.Debug("Executing script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); + ExecuteScript(content, scriptFile); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to execute script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); + + throw; + } + } + + /// + /// Gets execution metrics for performance monitoring + /// + public ScriptExecutionMetrics GetExecutionMetrics() + => new() + { + CacheHits = _cacheHits, + CacheMisses = _cacheMisses, + TotalScriptsCached = _scriptCache.Count + }; + + /// + /// Gets the statistics of the script engine. + /// + /// A tuple containing the module count, callback count, constant count, and initialization status. + public (int ModuleCount, int CallbackCount, int ConstantCount, bool IsInitialized) GetStats() + => (_loadedModules.Count, _callbacks.Count, _constants.Count, _isInitialized); + + /// + /// Registers a global variable in the Lua environment. + /// + /// The name of the variable. + /// The value of the variable. + public void RegisterGlobal(string name, object value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(value); + + LuaScript.Globals[name] = value; + _logger.Debug("Global registered: {Name} (Type: {Type})", name, value.GetType().Name); + } + + /// + /// Registers a global function in the Lua environment. + /// + /// The name of the function. + /// The delegate representing the function. + public void RegisterGlobalFunction(string name, Delegate func) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(func); + + LuaScript.Globals[name] = func; + _logger.Debug("Global function registered: {Name}", name); + } + + /// + /// Registers a global type user data. + /// + /// The type to register. + public void RegisterGlobalTypeUserData(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + _logger.Debug("Global type user data registered: {TypeName}", type.Name); + + LuaScript.Globals[type.Name] = UserData.CreateStatic(type); + } + + /// + /// Registers a global type user data for the specified type. + /// + /// The type to register. + public void RegisterGlobalTypeUserData() + { + var type = typeof(T); + _logger.Debug("Global type user data registered: {TypeName}", type.Name); + + LuaScript.Globals[type.Name] = UserData.CreateStatic(type); + } + + /// + /// Resets the script engine to its initial state. + /// + public void Reset() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + _loadedModules.Clear(); + _callbacks.Clear(); + _constants.Clear(); + _isInitialized = false; + + _logger.Debug("Lua engine reset"); + } + + /// + /// Stops the script engine asynchronously. + /// + /// A task representing the asynchronous operation. + public Task ShutdownAsync() + => Task.CompletedTask; + + /// + /// Starts the script engine asynchronously. + /// + /// A task representing the asynchronous operation. + public async Task StartAsync() + { + if (_isInitialized) + { + _logger.Warning("Script engine is already initialized"); + + return; + } + + try + { + await RegisterScriptModulesAsync(CancellationToken.None); + AttachLuaEventBridge(); + + // Hook for engine-side consumers to install UserData types, globals, and per-feature + // scanners (e.g. LuaComponentLoader) once the script is ready but before bootstrap runs. + AfterModulesRegistered?.Invoke(LuaScript); + + AddConstant("version", VersionUtils.GetVersion()); + AddConstant("engine", "Moongate"); + AddConstant("platform", PlatformUtils.GetCurrentPlatform().ToString()); + + _ = Task.Run(() => GenerateLuaMetaFileAsync(CancellationToken.None), CancellationToken.None); + + RegisterGlobalFunctions(); + + ExecuteBootstrap(); + + ExecuteBootFunction(); + _isInitialized = true; + _logger.Information("Lua engine initialized successfully"); + + if (_watcher == null) + { + _watcher = new(_engineConfig.ScriptsDirectory, "*.lua") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + IncludeSubdirectories = true, + EnableRaisingEvents = true + }; + + _watcher.Changed += OnLuaFilesChanged; + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to initialize Lua engine"); + + throw; + } + } + + /// + /// Converts a name to the script engine function name format. + /// + /// The name to convert. + /// The converted function name. + public string ToScriptEngineFunctionName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return _nameResolver(name); + } + + /// + /// Unregisters a global variable from the Lua environment. + /// + /// The name of the variable to unregister. + /// True if the variable was unregistered, false otherwise. + public bool UnregisterGlobal(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var existingValue = LuaScript.Globals.Get(name); + + if (existingValue.Type != DataType.Nil) + { + LuaScript.Globals[name] = DynValue.Nil; + _logger.Debug("Global unregistered: {Name}", name); + + return true; + } + + _logger.Warning("Attempted to unregister non-existent global: {Name}", name); + + return false; + } + + private void AttachLuaEventBridge() + { + var eventBridge = _serviceProvider.Resolve(IfUnresolved.ReturnDefault); + eventBridge?.Attach(LuaScript); + } + + private static object?[] ConvertArgumentsToArray(CallbackArguments args) + { + if (args.Count == 0) + { + return Array.Empty(); + } + + var converted = new object?[args.Count]; + + for (var i = 0; i < args.Count; i++) + { + converted[i] = args[i].ToObject(); + } + + return converted; + } + + private static object? ConvertFromLua(DynValue dynValue, Type targetType) + => dynValue.Type switch + { + DataType.Nil => null, + DataType.Boolean => dynValue.Boolean, + DataType.Number => Convert.ChangeType(dynValue.Number, targetType, CultureInfo.InvariantCulture), + DataType.String => dynValue.String, + DataType.Table => dynValue.ToObject(), + _ => dynValue.ToObject() + }; + + private DynValue ConvertToLua(object? value) + => value == null ? DynValue.Nil : DynValue.FromObject(LuaScript, value); + + /// + /// Creates a factory function that dynamically invokes the correct constructor. + /// Uses reflection to find the constructor matching the number of arguments passed from Lua. + /// + private Func CreateConstructorWrapper( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type + ) + { + // Cache constructors by parameter count for performance + var constructorsByParamCount = new Dictionary(); + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + foreach (var ctor in constructors) + { + var paramCount = ctor.GetParameters().Length; + constructorsByParamCount.TryAdd(paramCount, ctor); + } + + return (arg1, arg2, arg3, arg4) => + { + // Collect arguments + var rawArgs = new List(); + + if (arg1 != null) + { + rawArgs.Add(arg1); + } + + if (arg2 != null) + { + rawArgs.Add(arg2); + } + + if (arg3 != null) + { + rawArgs.Add(arg3); + } + + if (arg4 != null) + { + rawArgs.Add(arg4); + } + + var argCount = rawArgs.Count; + + // Find constructor with matching parameter count + if (constructorsByParamCount.TryGetValue(argCount, out var ctor)) + { + try + { + // Convert arguments to match constructor parameter types + var parameters = ctor.GetParameters(); + var convertedArgs = new object[argCount]; + + for (var i = 0; i < argCount; i++) + { + var paramType = parameters[i].ParameterType; + var argValue = rawArgs[i]; + + // Convert argument to the expected parameter type + if (argValue == null) + { + convertedArgs[i] = null; + } + else if (paramType.IsInstanceOfType(argValue)) + { + // No conversion needed + convertedArgs[i] = argValue; + } + else + { + // Convert using Convert.ChangeType (handles double -> float, etc.) + try + { + convertedArgs[i] = Convert.ChangeType( + argValue, + paramType, + CultureInfo.InvariantCulture + ); + } + catch + { + convertedArgs[i] = argValue; // Fallback to original value + } + } + } + + // Create instance with converted arguments + return Activator.CreateInstance(type, convertedArgs); + } + catch (Exception ex) + { + throw new ScriptRuntimeException( + $"Constructor of {type.Name} with {argCount} arguments failed: {ex.Message}", + ex + ); + } + } + + // No matching constructor found + var availableCtors = string.Join(", ", constructorsByParamCount.Keys.OrderBy(k => k)); + + throw new ScriptRuntimeException( + $"No constructor found for {type.Name} with {argCount} arguments. Available: {availableCtors}" + ); + }; + } + + /// + /// Creates detailed error information from a Lua exception + /// + private static ScriptErrorInfo CreateErrorInfo(ScriptRuntimeException luaEx, string sourceCode, string? fileName = null) + { + var errorInfo = new ScriptErrorInfo + { + Message = luaEx.DecoratedMessage ?? luaEx.Message, + StackTrace = luaEx.StackTrace, + LineNumber = 0, + ColumnNumber = 0, + ErrorType = "LuaError", + SourceCode = sourceCode, + FileName = fileName ?? "script.lua" + }; + + return errorInfo; + } + + /// + /// Creates detailed error information from a Lua interpreter exception (syntax errors, etc.) + /// + private static ScriptErrorInfo CreateErrorInfo(InterpreterException luaEx, string sourceCode, string? fileName = null) + { + // Extract line and column info from the exception message if available + // SyntaxErrorException typically has format like "chunk_1:(1,5-10): unexpected symbol near '?'" + int? lineNumber = null; + int? columnNumber = null; + var errorType = "LuaError"; + + if (luaEx is SyntaxErrorException) + { + errorType = "SyntaxError"; + } + + // Try to extract line and column from the message + var message = luaEx.Message; + + if (message.Contains('(')) + { + var match = Regex.Match(message, @"\((\d+),(\d+)"); + + if (match.Success) + { + lineNumber = int.Parse(match.Groups[1].Value, CultureInfo.CurrentCulture); + columnNumber = int.Parse(match.Groups[2].Value, CultureInfo.CurrentCulture); + } + } + + var errorInfo = new ScriptErrorInfo + { + Message = luaEx.DecoratedMessage ?? luaEx.Message, + StackTrace = luaEx.StackTrace, + LineNumber = lineNumber, + ColumnNumber = columnNumber, + ErrorType = errorType, + SourceCode = sourceCode, + FileName = fileName ?? "script.lua" + }; + + return errorInfo; + } + + private DynValue CreateMethodClosure(object instance, MethodInfo method) + => DynValue.NewCallback( + (context, args) => + { + try + { + var parameters = method.GetParameters(); + + // Check if the last parameter is a params array + var hasParamsArray = parameters.Length > 0 && + parameters[^1].IsDefined(typeof(ParamArrayAttribute), false); + + object?[] convertedArgs; + + if (hasParamsArray) + { + var regularParamsCount = parameters.Length - 1; + convertedArgs = new object?[parameters.Length]; + + // Convert regular parameters + for (var i = 0; i < regularParamsCount && i < args.Count; i++) + { + convertedArgs[i] = ConvertFromLua(args[i], parameters[i].ParameterType); + } + + // Collect remaining arguments into params array + var paramsArrayType = parameters[^1].ParameterType.GetElementType()!; + var paramsCount = Math.Max(0, args.Count - regularParamsCount); + var paramsArray = Array.CreateInstance(paramsArrayType, paramsCount); + + for (var i = 0; i < paramsCount; i++) + { + var argIndex = regularParamsCount + i; + paramsArray.SetValue(ConvertFromLua(args[argIndex], paramsArrayType), i); + } + + convertedArgs[^1] = paramsArray; + } + else + { + // Normal parameter handling + convertedArgs = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length && i < args.Count; i++) + { + convertedArgs[i] = ConvertFromLua(args[i], parameters[i].ParameterType); + } + } + + var result = method.Invoke(instance, convertedArgs); + + return method.ReturnType == typeof(void) ? DynValue.Nil : ConvertToLua(result); + } + catch (Exception ex) + { + _logger.Error(ex, "Error calling method {MethodName}", method.Name); + + throw new ScriptRuntimeException(ex.Message); + } + } + ); + + private Table CreateModuleTable( + object instance, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] + Type moduleType + ) + { + var moduleTable = new Table(LuaScript); + + var methods = moduleType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttribute() is not null); + + foreach (var method in methods) + { + var scriptFunctionAttr = method.GetCustomAttribute(); + + if (scriptFunctionAttr is null) + { + continue; + } + + var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; + + // Create a closure that captures the instance and method + var closure = CreateMethodClosure(instance, method); + moduleTable[functionName] = closure; + } + + return moduleTable; + } + + private void CreateNameResolver() + => _nameResolver = name => name.ToSnakeCase(); + + // _nameResolver = _scriptEngineConfig.ScriptNameConversion switch + // { + // ScriptNameConversion.CamelCase => name => name.ToCamelCase(), + // ScriptNameConversion.PascalCase => name => name.ToPascalCase(), + // ScriptNameConversion.SnakeCase => name => name.ToSnakeCase(), + // _ => _nameResolver + // }; + private Script CreateOptimizedEngine() + { + _scriptLoader = new(new[] { _engineConfig.ScriptsDirectory }); + var script = new Script + { + Options = + { + // Configure MoonSharp options + DebugPrint = s => _logger.Debug("[Lua] {Message}", s), + ScriptLoader = _scriptLoader + } + }; + + _logger.Debug("Lua script loader configured for require() functionality"); + + return script; + } + + private void ExecuteBootFunction() + => ExecuteFunctionFromBootstrap(OnReadyFunctionName); + + private void ExecuteBootstrap() + { + foreach (var file in _initScripts.Select(s => Path.Combine(_engineConfig.ScriptsDirectory, s))) + { + if (File.Exists(file)) + { + var fileName = Path.GetFileName(file); + _logger.Information("Executing {FileName} script", fileName); + ExecuteScriptFile(file); + } + } + } + + /// + /// Executes a script string with an optional file name for error reporting. + /// + /// The script to execute. + /// Optional file name for error reporting. + private void ExecuteScript(string script, string? fileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(script); + + var stopwatch = Stopwatch.GetTimestamp(); + + try + { + var scriptHash = GetScriptHash(script); + + if (_scriptCache.ContainsKey(scriptHash)) + { + Interlocked.Increment(ref _cacheHits); + _logger.Debug("Script found in cache"); + } + else + { + Interlocked.Increment(ref _cacheMisses); + _scriptCache.TryAdd(scriptHash, script); + } + + LuaScript.DoString(script); + var elapsedMs = Stopwatch.GetElapsedTime(stopwatch); + _logger.Debug("Script executed successfully in {ElapsedMs}ms", elapsedMs); + } + catch (ScriptRuntimeException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, script, fileName); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua error at line {Line}, column {Column}: {Message}", + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + throw; + } + catch (InterpreterException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, script, fileName); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua {ErrorType} at line {Line}, column {Column}: {Message}", + errorInfo.ErrorType, + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + throw; + } + catch (Exception e) + { + var elapsedMs = Stopwatch.GetElapsedTime(stopwatch); + _logger.Error( + e, + "Error executing script: {ScriptPreview}", + script.Length > 100 ? script[..100] + "..." : script + ); + + throw; + } + } + + [RequiresUnreferencedCode( + "Lua meta generation relies on reflection-heavy LuaDocumentationGenerator which is not trim-safe." + )] + private async Task GenerateLuaMetaFileAsync(CancellationToken cancellationToken) + { + try + { + _logger.Debug("Generating Lua meta files"); + + var definitionDirectory = _engineConfig.LuarcDirectory; + + if (!Directory.Exists(definitionDirectory)) + { + Directory.CreateDirectory(definitionDirectory); + } + + foreach (var userData in _loadedUserData) + { + // check if is enum + + if (userData.UserType.IsEnum) + { + LuaDocumentationGenerator.FoundEnums.Add(userData.UserType); + + continue; + } + + LuaDocumentationGenerator.AddClassToGenerate(userData.UserType); + } + + AddConstant("engine_version", _engineConfig.EngineVersion); + + // Generate meta.lua + var manualModulesSnapshot = _manualModuleFunctions.ToDictionary( + kvp => kvp.Key, + IReadOnlyCollection (kvp) => kvp.Value.Keys.ToArray() + ); + + var documentation = LuaDocumentationGenerator.GenerateDocumentation( + "Moongate", + _engineConfig.EngineVersion, + _scriptModules, + new(_constants), + manualModulesSnapshot, + _nameResolver + ); + + var metaLuaPath = Path.Combine(definitionDirectory, "definitions.lua"); + await File.WriteAllTextAsync(metaLuaPath, documentation, cancellationToken); + _logger.Debug("Lua meta file generated at {Path}", metaLuaPath); + + // Generate .luarc.json + var luarcJson = GenerateLuarcJson(); + var luarcPath = Path.Combine(_engineConfig.LuarcDirectory, ".luarc.json"); + await File.WriteAllTextAsync(luarcPath, luarcJson, cancellationToken); + _logger.Debug("Lua configuration file generated at {Path}", luarcPath); + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to generate Lua meta files"); + } + } + + private string GenerateLuarcJson() + { + var globalsList = _constants.Keys.ToList(); + globalsList.AddRange(_completionExcludedGlobals); + + // Add registered user data types (Vector3, Vector2, Quaternion, etc.) + foreach (var userData in _loadedUserData) + { + globalsList.Add(userData.UserType.Name); + } + + var luarcConfig = new LuarcConfig + { + Runtime = new() + { + Path = + [ + "?.lua", + "?/init.lua", + "modules/?.lua", + "modules/?/init.lua" + ] + }, + Workspace = new() + { + Library = [_engineConfig.ScriptsDirectory] + }, + Diagnostics = new() + { + Globals = [..globalsList] + } + }; + + return JsonUtils.Serialize(luarcConfig); + } + + /// + /// Generates a hash for script caching + /// + private static string GetScriptHash(string script) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(script)); + + return Convert.ToBase64String(hashBytes); + } + + private static bool IsSimpleType(Type type) + => type.IsPrimitive || type == typeof(string) || type.IsEnum; + + private void LoadToUserData() + { + if (_loadedUserData == null) + { + return; + } + + foreach (var scriptUserData in _loadedUserData) + { + // Register the type to allow MoonSharp to access its members and methods + UserData.RegisterType(scriptUserData.UserType); + + // Check if type has public constructors (instantiable) + var publicConstructors = scriptUserData.UserType.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (publicConstructors.Length > 0) + { + // Instantiable type - use constructor wrapper for easier instance creation + var constructorWrapper = CreateConstructorWrapper(scriptUserData.UserType); + LuaScript.Globals[scriptUserData.UserType.Name] = constructorWrapper; + } + else + { + // Static class or no public constructors - expose the type itself for static method access + LuaScript.Globals[scriptUserData.UserType.Name] = scriptUserData.UserType; + } + + _logger.Debug("User data type registered: {TypeName}", scriptUserData.UserType.Name); + + LuaDocumentationGenerator.AddClassToGenerate(scriptUserData.UserType); + } + } + + private Table ObjectToTable(object obj) + { + var table = new Table(LuaScript); + var properties = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + var value = prop.GetValue(obj); + table[prop.Name] = value; + } + + return table; + } + + private void OnLuaFilesChanged(object sender, FileSystemEventArgs e) + { + if (_initScripts.Contains(e.Name)) + { + _logger.Information("Lua script file changed: {FileName}. Clearing script cache.", e.Name); + + if (FileChanged != null) + { + ClearScriptCache(); + + if (FileChanged(e.FullPath)) + { + _logger.Information("File change handled successfully: {FileName}", e.Name); + + ExecuteBootstrap(); + ExecuteBootFunction(); + } + } + + return; + } + + // Route changes under any `components/` subdirectory to the engine-side component loader. + var sep = Path.DirectorySeparatorChar; + + if (e.FullPath.Contains($"{sep}components{sep}", StringComparison.OrdinalIgnoreCase)) + { + _logger.Information("Lua component file changed: {FileName}", e.Name); + OnComponentFileChanged?.Invoke(e.FullPath); + } + } + + private TInput? PrepareManualInput(CallbackArguments args) + { + if (typeof(TInput) == typeof(object[])) + { + return (TInput?)(object?)ConvertArgumentsToArray(args); + } + + if (args.Count == 0) + { + return default; + } + + var firstArg = args[0]; + var converted = ConvertFromLua(firstArg, typeof(TInput)); + + return converted is null ? default : (TInput?)converted; + } + + private (string ModuleName, string FunctionName, Table ModuleTable) PrepareManualModule( + string moduleName, + string functionName + ) + { + var normalizedModuleName = _nameResolver(moduleName); + var normalizedFunctionName = _nameResolver(functionName); + + var existing = LuaScript.Globals.Get(normalizedModuleName); + Table moduleTable; + + if (existing.Type == DataType.Table) + { + moduleTable = existing.Table; + } + else + { + moduleTable = new(LuaScript); + LuaScript.Globals[normalizedModuleName] = moduleTable; + } + + _loadedModules.TryAdd(normalizedModuleName, moduleTable); + + return (normalizedModuleName, normalizedFunctionName, moduleTable); + } + + [RequiresUnreferencedCode("Enum registration uses reflection to access enum metadata.")] + private void RegisterEnum(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + if (!enumType.IsEnum) + { + _logger.Warning("Type {TypeName} is not an enum, skipping registration", enumType.Name); + + return; + } + + var enumName = _nameResolver(enumType.Name); + var enumTable = new Table(LuaScript); + var enumValuesByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Populate enum values + var names = Enum.GetNames(enumType); + var underlyingValues = Enum.GetValuesAsUnderlyingType(enumType); + + for (var i = 0; i < names.Length; i++) + { + var name = names[i]; + var rawValue = underlyingValues.GetValue(i); + + if (rawValue is null) + { + continue; + } + + var coercedValue = Convert.ToInt32(rawValue, CultureInfo.InvariantCulture); + enumTable[name] = coercedValue; + enumValuesByName[name] = coercedValue; + } + + // Create metatable for read-only and case-insensitive access + var metatable = new Table(LuaScript); + + // __index: allows case-insensitive access + metatable["__index"] = DynValue.NewCallback( + (ctx, args) => + { + var key = args[1].String; + + if (string.IsNullOrEmpty(key)) + { + return DynValue.Nil; + } + + // Try exact match first + var value = enumTable.Get(key); + + if (value.Type != DataType.Nil) + { + return value; + } + + // Try case-insensitive match + if (enumValuesByName.TryGetValue(key, out var intValue)) + { + return DynValue.NewNumber(intValue); + } + + _logger.Warning( + "Attempt to access undefined enum value {EnumName}.{ValueName}", + enumName, + key + ); + + return DynValue.Nil; + } + ); + + // __newindex: prevents modifications (read-only) + metatable["__newindex"] = DynValue.NewCallback( + (ctx, args) => + { + var key = args[1].String; + + throw new ScriptRuntimeException($"Cannot modify enum {enumName}.{key}: enums are read-only"); + } + ); + + // __tostring: pretty print + metatable["__tostring"] = DynValue.NewCallback( + (ctx, args) => + { + return DynValue.NewString($"enum<{enumName}>"); + } + ); + + // Set the enum table first + var enumTableDynValue = DynValue.NewTable(enumTable); + + // Try to apply metatable (may not work perfectly in all MoonSharp versions) + try + { + // Create a reference for the metatable + var metatableValue = DynValue.NewTable(metatable); + enumTable.MetaTable = metatable; + } + catch + { + _logger.Warning("Could not apply metatable to enum {EnumName}, using fallback", enumName); + } + + // Register the enum table in globals + LuaScript.Globals[enumName] = enumTableDynValue; + + _logger.Debug( + "Registered enum {EnumName} with {ValueCount} values (read-only, case-insensitive)", + enumName, + enumValuesByName.Count + ); + } + + [RequiresUnreferencedCode("Enum metadata is discovered dynamically when building Lua documentation.")] + private void RegisterEnums() + { + var enumsFound = LuaDocumentationGenerator.FoundEnums.ToArray(); + + foreach (var enumType in enumsFound) + { + RegisterEnum(enumType); + } + } + + private void RegisterGlobalFunctions() + { + LuaScript.Globals["delay"] = (Func)(async milliseconds => + { + await Task.Delay(Math.Min(milliseconds, 5000)); + }); + + // NOTE: do NOT define a bare 'log' global here — the LogModule registered above already + // exposes 'log.info / log.warning / log.error' as a table; overwriting it with a function + // here would shadow that table and break log.info(...) calls from scripts. + + LuaScript.Globals["toString"] = (Func)(obj => obj?.ToString() ?? "nil"); + } + + private void RegisterManualModuleFunction(string moduleName, string functionName) + { + var functions = _manualModuleFunctions.GetOrAdd(moduleName, _ => new()); + functions.TryAdd(functionName, 0); + } + + private async Task RegisterScriptModulesAsync(CancellationToken cancellationToken) + { + foreach (var module in _scriptModules) + { + cancellationToken.ThrowIfCancellationRequested(); + + var scriptModuleAttribute = module.ModuleType.GetCustomAttribute(); + + if (scriptModuleAttribute is null) + { + continue; + } + + if (!_serviceProvider.IsRegistered(module.ModuleType)) + { + _serviceProvider.Register(module.ModuleType, Reuse.Singleton); + } + + var instance = _serviceProvider.GetService(module.ModuleType); + + if (instance is null) + { + throw new InvalidOperationException($"Unable to create instance of script module {module.ModuleType.Name}"); + } + + var moduleName = scriptModuleAttribute.Name; + _logger.Debug("Registering script module {Name}", moduleName); + + // Register the type with MoonSharp + UserData.RegisterType(module.ModuleType, InteropAccessMode.Reflection); + + // Create a table for the module + var moduleTable = CreateModuleTable(instance, module.ModuleType); + LuaScript.Globals[moduleName] = moduleTable; + + _loadedModules[moduleName] = instance; + } + + RegisterEnums(); + } +} diff --git a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj new file mode 100644 index 00000000..d4f89a31 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + diff --git a/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs b/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs new file mode 100644 index 00000000..bc69e34c --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs @@ -0,0 +1,1054 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Text; +using SquidStd.Core.Extensions.Strings; +using SquidStd.Scripting.Lua.Attributes; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Data.Internal; + +namespace SquidStd.Scripting.Lua.Utils; + +/// +/// Utility class for generating Lua meta files with EmmyLua/LuaLS annotations +/// Automatically creates meta.lua files with function signatures, types, and documentation +/// +[RequiresUnreferencedCode( + "This class uses reflection to analyze types for Lua meta generation and requires full type metadata." +)] +public static class LuaDocumentationGenerator +{ + private static readonly HashSet _processedTypes = new(); + private static readonly StringBuilder _classesBuilder = new(); + private static readonly StringBuilder _constantsBuilder = new(); + private static readonly StringBuilder _enumsBuilder = new(); + private static readonly HashSet _classTypesToGenerate = new(); + private static readonly Dictionary _recordTypeCache = new(); + private static readonly Lock _syncLock = new(); + + private static Func _nameResolver = name => name.ToSnakeCase(); + + /// + /// List of enums found during documentation generation + /// + public static List FoundEnums { get; } = new(16); + + /// + /// Adds a class type to be generated in the documentation + /// + public static void AddClassToGenerate(Type type) + { + ArgumentNullException.ThrowIfNull(type); + _classTypesToGenerate.Add(type); + } + + /// + /// Clears all internal caches and state + /// + public static void ClearCaches() + { + lock (_syncLock) + { + _processedTypes.Clear(); + _recordTypeCache.Clear(); + _classTypesToGenerate.Clear(); + FoundEnums.Clear(); + _classesBuilder.Clear(); + _constantsBuilder.Clear(); + _enumsBuilder.Clear(); + } + } + + [SuppressMessage("Trimming", "IL2075:Reflection", Justification = "Reflection is required for script module analysis"), + SuppressMessage( + "Trimming", + "IL2072:Reflection", + Justification = "Reflection is required for parameter and return type analysis" + )] + + /// + /// Generates Lua documentation meta file with all module functions, classes, and constants + /// + public static string GenerateDocumentation( + string appName, + string appVersion, + List scriptModules, + Dictionary constants, + Dictionary> manualModules, + Func? nameResolver = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(appName); + ArgumentException.ThrowIfNullOrWhiteSpace(appVersion); + ArgumentNullException.ThrowIfNull(scriptModules); + ArgumentNullException.ThrowIfNull(constants); + ArgumentNullException.ThrowIfNull(manualModules); + + lock (_syncLock) + { + if (nameResolver != null) + { + _nameResolver = nameResolver; + } + + var sb = new StringBuilder(); + sb.AppendLine("---@meta"); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {appName} v{appVersion} Lua API"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- Auto-generated on {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine("---"); + sb.AppendLine(); + + // Save data before clearing (they may have been added via AddClassToGenerate, AddCommonXnaTypesToGenerate, or FoundEnums) + var typesToGenerate = new List(_classTypesToGenerate); + var foundEnums = new List(FoundEnums); + + // Reset processed types and builders + _processedTypes.Clear(); + _classesBuilder.Clear(); + _constantsBuilder.Clear(); + _enumsBuilder.Clear(); + _classTypesToGenerate.Clear(); + FoundEnums.Clear(); + + // Restore types and enums to generate + foreach (var type in typesToGenerate) + { + _classTypesToGenerate.Add(type); + } + + foreach (var enumType in foundEnums) + { + FoundEnums.Add(enumType); + } + + var distinctConstants = constants + .GroupBy(kvp => kvp.Key) + .ToDictionary(g => g.Key, g => g.First().Value); + + ProcessConstants(distinctConstants); + sb.Append(_constantsBuilder); + + foreach (var module in scriptModules) + { + var scriptModuleAttribute = module.ModuleType.GetCustomAttribute(); + + if (scriptModuleAttribute is null) + { + continue; + } + + var moduleName = scriptModuleAttribute.Name; + var moduleHelpText = scriptModuleAttribute.HelpText; + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {module.ModuleType.Name} module"); + + if (!string.IsNullOrWhiteSpace(moduleHelpText)) + { + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {moduleHelpText}"); + } + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"---@class {moduleName}"); + + // Get all methods with ScriptFunction attribute + var methods = module.ModuleType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttribute() is not null) + .ToList(); + + foreach (var method in methods) + { + var scriptFunctionAttr = method.GetCustomAttribute(); + + if (scriptFunctionAttr is null) + { + continue; + } + + var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; + + sb.AppendLine(CultureInfo.InvariantCulture, $"{moduleName}.{functionName} = function() end"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $"{moduleName} = {{}}"); + sb.AppendLine(); + + // Now generate detailed function documentation + foreach (var method in methods) + { + var scriptFunctionAttr = method.GetCustomAttribute(); + + if (scriptFunctionAttr is null) + { + continue; + } + + var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; + var description = scriptFunctionAttr.HelpText ?? "No description available"; + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {description}"); + sb.AppendLine("---"); + + // Add parameter documentation + var parameters = method.GetParameters(); + + foreach (var param in parameters) + { + var isParams = param.IsDefined(typeof(ParamArrayAttribute), false); + var paramType = isParams + ? ConvertToLuaType(param.ParameterType.GetElementType()!) + : ConvertToLuaType(param.ParameterType); + var paramName = isParams ? "..." : param.Name ?? $"param{Array.IndexOf(parameters, param)}"; + var paramDescription = GetParameterDescription(param, paramType); + sb.AppendLine( + CultureInfo.InvariantCulture, + $"---@param {_nameResolver(paramName)} {paramType} {paramDescription}" + ); + } + + // Add return type documentation + if (method.ReturnType != typeof(void) && method.ReturnType != typeof(Task)) + { + var returnType = ConvertToLuaType(method.ReturnType); + var returnDescription = GetReturnDescription(method.ReturnType, returnType); + sb.AppendLine(CultureInfo.InvariantCulture, $"---@return {returnType} {returnDescription}"); + } + + // Function signature + sb.Append(CultureInfo.InvariantCulture, $"function {moduleName}.{functionName}("); + + for (var i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var isParams = param.IsDefined(typeof(ParamArrayAttribute), false); + var paramName = isParams ? "..." : param.Name ?? $"param{i}"; + sb.Append(_nameResolver(paramName)); + + if (i < parameters.Length - 1) + { + sb.Append(", "); + } + } + + sb.AppendLine(") end"); + sb.AppendLine(); + } + } + + if (manualModules.Count > 0) + { + foreach (var manualModule in manualModules.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) + { + var moduleName = manualModule.Key; + var functions = manualModule.Value; + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {moduleName} module "); + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"---@class {moduleName}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"{moduleName} = {{}}"); + sb.AppendLine(); + + foreach (var function in functions.OrderBy(f => f, StringComparer.Ordinal)) + { + sb.AppendLine("---"); + sb.AppendLine("--- Dynamically registered function"); + sb.AppendLine("---"); + sb.AppendLine("---@param ... any"); + sb.AppendLine("---@return any"); + sb.AppendLine(CultureInfo.InvariantCulture, $"function {moduleName}.{function}(...) end"); + sb.AppendLine(); + } + } + } + + // Generate all classes that were collected during type conversion + GenerateAllClasses(); + + // Append enums and classes + sb.Append(_enumsBuilder); + sb.AppendLine(); + sb.Append(_classesBuilder); + + // Add global declarations for all registered types + sb.AppendLine(); + sb.AppendLine("--- Global type constructors"); + + foreach (var type in _classTypesToGenerate) + { + var typeName = type.Name; + sb.AppendLine(CultureInfo.InvariantCulture, $"{typeName} = {{}}"); + } + + return sb.ToString(); + } + } + + private static bool CanProcessType(Type type) + => true; + + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for Lua type conversion"), + SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for Lua type conversion"), + SuppressMessage("Trimming", "IL2062:Reflection", Justification = "Reflection is required for Lua type conversion")] + private static string ConvertToLuaType( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicConstructors + )] + Type type + ) + { + ArgumentNullException.ThrowIfNull(type); + + // Handle ref/out/in parameters (ByRef types) + if (type.IsByRef) + { + var underlyingType = type.GetElementType(); + + if (underlyingType is not null) + { + return ConvertToLuaType(underlyingType); + } + } + + // Handle void + if (type == typeof(void)) + { + return "nil"; + } + + // Handle string + if (type == typeof(string)) + { + return "string"; + } + + // Handle numbers + if (type == typeof(int) || + type == typeof(long) || + type == typeof(float) || + type == typeof(double) || + type == typeof(decimal) || + type == typeof(short) || + type == typeof(ushort) || + type == typeof(uint) || + type == typeof(ulong) || + type == typeof(byte) || + type == typeof(sbyte)) + { + return "number"; + } + + // Handle boolean + if (type == typeof(bool)) + { + return "boolean"; + } + + // Handle Guid + if (type == typeof(Guid)) + { + return "string"; + } + + // Handle DateTime types + if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) + { + return "number"; + } + + // Handle TimeSpan + if (type == typeof(TimeSpan)) + { + return "number"; + } + + // Handle object + if (type == typeof(object)) + { + return "any"; + } + + // Handle Task (async) + if (type == typeof(Task)) + { + return "nil"; + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)) + { + var taskResultType = type.GetGenericArguments()[0]; + + return ConvertToLuaType(taskResultType); + } + + // Handle Span and Memory types + if (type.IsGenericType) + { + var typeDef = type.GetGenericTypeDefinition(); + + if (typeDef == typeof(Span<>) || + typeDef == typeof(Memory<>) || + typeDef == typeof(ReadOnlySpan<>) || + typeDef == typeof(ReadOnlyMemory<>)) + { + var elementType = type.GetGenericArguments()[0]; + + return $"{ConvertToLuaType(elementType)}[]"; + } + } + + // Handle arrays + if (type.IsArray) + { + var elementType = type.GetElementType(); + + if (elementType is null) + { + return "table"; + } + + return $"{ConvertToLuaType(elementType)}[]"; + } + + // Handle tuples + if (type.IsGenericType && type.Name.StartsWith("ValueTuple", StringComparison.Ordinal)) + { + var tupleArgs = type.GetGenericArguments(); + var typeList = string.Join(", ", tupleArgs.Select(ConvertToLuaType)); + + return $"table"; + } + + // Handle nullable types + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + var underlyingType = Nullable.GetUnderlyingType(type); + + if (underlyingType is null) + { + return "any"; + } + + return $"{ConvertToLuaType(underlyingType)}?"; + } + + // Handle generic types + if (type.IsGenericType) + { + var genericTypeDefinition = type.GetGenericTypeDefinition(); + var genericArgs = type.GetGenericArguments(); + + // Handle Dictionary + if (genericTypeDefinition == typeof(Dictionary<,>)) + { + var keyType = ConvertToLuaType(genericArgs[0]); + var valueType = ConvertToLuaType(genericArgs[1]); + + return $"table<{keyType}, {valueType}>"; + } + + // Handle IReadOnlyDictionary + if (genericTypeDefinition == typeof(IReadOnlyDictionary<,>)) + { + var keyType = ConvertToLuaType(genericArgs[0]); + var valueType = ConvertToLuaType(genericArgs[1]); + + return $"table<{keyType}, {valueType}>"; + } + + // Handle List, IEnumerable, ICollection, IList, IReadOnlyList, IReadOnlyCollection + if (genericTypeDefinition == typeof(List<>) || + genericTypeDefinition == typeof(IEnumerable<>) || + genericTypeDefinition == typeof(ICollection<>) || + genericTypeDefinition == typeof(IList<>) || + genericTypeDefinition == typeof(IReadOnlyList<>) || + genericTypeDefinition == typeof(IReadOnlyCollection<>)) + { + return $"{ConvertToLuaType(genericArgs[0])}[]"; + } + + // Handle Action delegates + if (genericTypeDefinition == typeof(Action) || + genericTypeDefinition.Name.StartsWith("Action`", StringComparison.Ordinal)) + { + return "fun()"; + } + + // Handle Func delegates + if (genericTypeDefinition.Name.StartsWith("Func`", StringComparison.Ordinal)) + { + var returnType = ConvertToLuaType(genericArgs[^1]); + + return $"fun():{returnType}"; + } + } + + // Handle enums + if (type.IsEnum) + { + GenerateEnumClass(type); + + return _nameResolver(type.Name); + } + + // Handle MoonSharp Closure (represents Lua functions) + if (type.Name == "Closure" && type.Namespace == "MoonSharp.Interpreter") + { + return "function"; + } + + // Handle record types + if (IsRecordType(type)) + { + var className = type.Name; + + if (_processedTypes.Contains(type)) + { + return className; + } + + _classTypesToGenerate.Add(type); + + return className; + } + + // Handle other complex types + if ((type.IsClass || type.IsValueType) && + !type.IsPrimitive && + type.Namespace is not null && + !type.Namespace.StartsWith("System", StringComparison.Ordinal)) + { + var className = type.Name; + + if (_processedTypes.Contains(type)) + { + return className; + } + + _classTypesToGenerate.Add(type); + + return className; + } + + // Handle delegates + if (typeof(Delegate).IsAssignableFrom(type)) + { + return "function"; + } + + return "any"; + } + + [SuppressMessage( + "Trimming", + "IL2072:Reflection", + Justification = "Reflection is required for constant value formatting" + )] + private static string FormatConstantValue(object? value, Type type) + { + ArgumentNullException.ThrowIfNull(type); + + if (value is null) + { + return "nil"; + } + + if (type == typeof(string)) + { + return $"\"{value}\""; + } + + if (type == typeof(bool)) + { + return value.ToString()!.ToLowerInvariant(); + } + + if (type.IsEnum) + { + return $"{_nameResolver(type.Name)}.{value}"; + } + + return value.ToString() ?? "nil"; + } + + /// + /// Generate all classes after collecting them, with robust circular dependency handling + /// + private static void GenerateAllClasses() + { + var processedInIteration = new HashSet(); + var remainingTypes = new List(_classTypesToGenerate); + var maxIterations = remainingTypes.Count + 5; + var iterationCount = 0; + + while (remainingTypes.Count > 0 && iterationCount < maxIterations) + { + iterationCount++; + processedInIteration.Clear(); + + for (var i = remainingTypes.Count - 1; i >= 0; i--) + { + var type = remainingTypes[i]; + + if (_processedTypes.Contains(type)) + { + remainingTypes.RemoveAt(i); + + continue; + } + + if (CanProcessType(type)) + { + GenerateClass(type); + processedInIteration.Add(type); + remainingTypes.RemoveAt(i); + } + } + + // If no progress was made, force process remaining types to avoid infinite loop + if (processedInIteration.Count == 0 && remainingTypes.Count > 0) + { + foreach (var type in remainingTypes) + { + if (!_processedTypes.Contains(type)) + { + GenerateClass(type); + } + } + + break; + } + } + } + + /// + /// Generate a single class with properties, constructors, and methods + /// + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for class generation"), + SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for class generation")] + private static void GenerateClass( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicMethods + )] + Type type + ) + { + ArgumentNullException.ThrowIfNull(type); + + if (!_processedTypes.Add(type)) + { + return; + } + + var className = type.Name; + + _classesBuilder.AppendLine(); + _classesBuilder.AppendLine("---"); + + if (IsRecordType(type)) + { + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Record type {type.FullName}"); + } + else + { + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Class {type.FullName}"); + } + + _classesBuilder.AppendLine("---"); + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"---@class {className}"); + + // Generate properties with documentation (exclude indexers which have parameters) + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .ToList(); + + foreach (var property in properties) + { + var propertyName = property.Name; + + if (string.IsNullOrEmpty(propertyName)) + { + continue; + } + + var propertyType = ConvertToLuaType(property.PropertyType); + var xmlDocAttr = property.GetCustomAttribute(); + var description = xmlDocAttr?.Description ?? "Property"; + var luaFieldAttr = property.GetCustomAttribute(); + + // Use original property name for built-in types (XNA), apply resolver for custom types + var displayName = luaFieldAttr?.Name ?? + (type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true + ? propertyName + : _nameResolver(propertyName)); + + _classesBuilder.AppendLine( + CultureInfo.InvariantCulture, + $"---@field {displayName} {propertyType} # {description}" + ); + } + + // Generate public constructors + var constructors = type.GetConstructors(BindingFlags.Public) + .Where(c => c.GetParameters().Length > 0) + .ToList(); + + if (constructors.Count > 0) + { + _classesBuilder.AppendLine("---"); + _classesBuilder.AppendLine("--- Constructors:"); + + // Check if it's an XNA type to preserve original names + var isXnaType = type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true; + + foreach (var constructor in constructors) + { + _classesBuilder.Append("---@overload fun("); + + var parameters = constructor.GetParameters(); + + for (var i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var paramType = ConvertToLuaType(param.ParameterType); + + // Use original parameter names for XNA types, apply resolver for custom types + var paramName = isXnaType + ? param.Name ?? $"param{i}" + : _nameResolver(param.Name ?? $"param{i}"); + _classesBuilder.Append(CultureInfo.InvariantCulture, $"{paramName}: {paramType}"); + + if (i < parameters.Length - 1) + { + _classesBuilder.Append(", "); + } + } + + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"):void"); + } + } + + // Generate public methods (excluding getters/setters from properties) + var propertyMethods = new HashSet(); + + foreach (var prop in properties) + { + if (prop.GetMethod != null) + { + propertyMethods.Add(prop.GetMethod.Name); + } + + if (prop.SetMethod != null) + { + propertyMethods.Add(prop.SetMethod.Name); + } + } + + // Methods to exclude (system methods that are not useful for Lua) + var excludedMethods = new HashSet + { + "GetHashCode", + "Equals", + "ToString", + "GetType", + "MemberwiseClone" + }; + + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where( + m => !propertyMethods.Contains(m.Name) && + !m.IsSpecialName && + !excludedMethods.Contains(m.Name) + ) + .ToList(); + + if (methods.Count > 0) + { + _classesBuilder.AppendLine("---"); + _classesBuilder.AppendLine("--- Methods:"); + + // Group methods by name to handle overloads + var methodsByName = methods.GroupBy(m => m.Name); + + // Check if it's an XNA type to preserve original names + var isXnaType = type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true; + + foreach (var methodGroup in methodsByName) + { + var methodName = methodGroup.Key; + + foreach (var method in methodGroup) + { + var returnType = method.ReturnType == typeof(void) + ? "nil" + : ConvertToLuaType(method.ReturnType); + + var parameters = method.GetParameters(); + + _classesBuilder.Append("---@overload fun("); + + for (var i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var paramType = ConvertToLuaType(param.ParameterType); + + // Use original parameter names for XNA types, apply resolver for custom types + var paramName = isXnaType + ? param.Name ?? $"param{i}" + : _nameResolver(param.Name ?? $"param{i}"); + _classesBuilder.Append(CultureInfo.InvariantCulture, $"{paramName}: {paramType}"); + + if (i < parameters.Length - 1) + { + _classesBuilder.Append(", "); + } + } + + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"):{returnType}"); + } + } + } + + _classesBuilder.AppendLine(); + } + + [SuppressMessage( + "Trimming", + "IL2070:Reflection", + Justification = "Reflection is required for enum class generation" + )] + private static void GenerateEnumClass(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + if (!enumType.IsEnum) + { + throw new ArgumentException("Type must be an enum", nameof(enumType)); + } + + if (!_processedTypes.Add(enumType)) + { + return; + } + + FoundEnums.Add(enumType); + + _enumsBuilder.AppendLine(); + _enumsBuilder.AppendLine("---"); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Enum: {enumType.FullName}"); + _enumsBuilder.AppendLine("--- This enum is read-only and case-insensitive"); + _enumsBuilder.AppendLine("---"); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"---@class {_nameResolver(enumType.Name)}"); + + var enumValues = Enum.GetNames(enumType); + var enumUnderlyingType = Enum.GetUnderlyingType(enumType); + + foreach (var value in enumValues) + { + try + { + var enumValue = Enum.Parse(enumType, value); + var numericValue = Convert.ChangeType(enumValue, enumUnderlyingType, CultureInfo.InvariantCulture); + _enumsBuilder.AppendLine( + CultureInfo.InvariantCulture, + $"---@field public readonly {value} number # Enum value: {numericValue}" + ); + } + catch (Exception ex) when (ex is InvalidCastException or OverflowException) + { + _enumsBuilder.AppendLine( + CultureInfo.InvariantCulture, + $"---@field public readonly {value} number # Unable to determine value" + ); + } + } + + _enumsBuilder.AppendLine(); + var enumTypeName = _nameResolver(enumType.Name); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Read-only enum table"); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"{enumTypeName} = {{}}"); + _enumsBuilder.AppendLine(); + } + + /// + /// Gets enhanced parameter description with type information + /// + private static string GetParameterDescription(ParameterInfo param, string luaType) + { + var isParams = param.IsDefined(typeof(ParamArrayAttribute), false); + var baseName = param.Name ?? "parameter"; + + string description; + + if (isParams) + { + description = "The arguments"; + } + else + { + description = $"The {baseName.ToLowerInvariant()}"; + } + + if (luaType.Contains("number")) + { + description += isParams ? " values" : " value"; + } + else if (luaType.Contains("string")) + { + description += isParams ? " texts" : " text"; + } + else if (luaType.Contains("boolean")) + { + description += isParams ? " flags" : " flag"; + } + else if (luaType.Contains("[]") || luaType.Contains("table")) + { + description += " table"; + } + else + { + description += isParams ? $" of type {luaType}" : $" of type {luaType}"; + } + + if (param.IsOptional && !isParams) + { + description += " (optional)"; + } + + return description; + } + + /// + /// Gets enhanced return description with type information + /// + private static string GetReturnDescription(Type returnType, string luaType) + { + var description = "The "; + + if (luaType.Contains("number")) + { + description += "computed numeric value"; + } + else if (luaType.Contains("string")) + { + description += "resulting text"; + } + else if (luaType.Contains("boolean")) + { + description += "result of the operation"; + } + else if (luaType.Contains("[]") || luaType.Contains("table")) + { + description += "collection of results"; + } + else if (luaType.Contains("nil")) + { + description += "operation completes without returning a value"; + } + else + { + description += $"result as {luaType}"; + } + + return description; + } + + /// + /// Check if a type is a C# record type + /// + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for record type detection")] + private static bool IsRecordType( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.NonPublicProperties | DynamicallyAccessedMemberTypes.PublicMethods + )] + Type type + ) + { + ArgumentNullException.ThrowIfNull(type); + + if (_recordTypeCache.TryGetValue(type, out var isRecord)) + { + return isRecord; + } + + if (!type.IsClass) + { + _recordTypeCache[type] = false; + + return false; + } + + var equalityContract = type.GetProperty( + "EqualityContract", + BindingFlags.NonPublic | BindingFlags.Instance + ); + + if (equalityContract is not null && equalityContract.PropertyType == typeof(Type)) + { + _recordTypeCache[type] = true; + + return true; + } + + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + var hasCompilerGeneratedToString = methods.Any( + m => + m.Name == "ToString" && + m.GetParameters().Length == 0 && + m.GetCustomAttributes().Any(attr => attr.GetType().Name.Contains("CompilerGenerated")) + ); + + var result = hasCompilerGeneratedToString; + _recordTypeCache[type] = result; + + return result; + } + + [SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for constant type analysis")] + private static void ProcessConstants(Dictionary constants) + { + ArgumentNullException.ThrowIfNull(constants); + + if (constants.Count == 0) + { + return; + } + + _constantsBuilder.AppendLine("--- Global constants"); + _constantsBuilder.AppendLine(); + + foreach (var constant in constants) + { + var constantName = constant.Key ?? "unnamed"; + var constantValue = constant.Value; + var constantType = constantValue?.GetType() ?? typeof(object); + + var luaType = ConvertToLuaType(constantType); + var formattedValue = FormatConstantValue(constantValue, constantType); + + _constantsBuilder.AppendLine("---"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- {constantName} constant"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Value: {formattedValue}"); + _constantsBuilder.AppendLine("---"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"---@type {luaType}"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"{constantName} = {formattedValue}"); + _constantsBuilder.AppendLine(); + } + + _constantsBuilder.AppendLine(); + } +} diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 0af02c63..ea1d6a58 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -22,6 +22,24 @@ public static class RegisterDefaultServicesExtensions /// Container that receives the core service registrations. extension(IContainer container) { + /// + /// Registers the default config manager service as a singleton instance. + /// + /// The logical config name or YAML file name. + /// The directory where the config file is searched. + /// The same container for chaining. + public IContainer RegisterConfigManagerService(string configName, string configDirectory) + { + var service = new ConfigManagerService(container, configName, configDirectory); + container.RegisterInstance(service, IfAlreadyRegistered.Replace); + container.RegisterInstance(service, IfAlreadyRegistered.Replace); + container.AddToRegisterTypedList( + new ServiceRegistrationData(typeof(IConfigManagerService), typeof(ConfigManagerService), -1000) + ); + + return container; + } + /// /// Registers the default SquidStd core services using the default config file location. /// @@ -47,32 +65,14 @@ public IContainer RegisterCoreServices(string configName, string configDirectory return container; } - /// - /// Registers the default config manager service as a singleton instance. - /// - /// The logical config name or YAML file name. - /// The directory where the config file is searched. - /// The same container for chaining. - public IContainer RegisterConfigManagerService(string configName, string configDirectory) - { - var service = new ConfigManagerService(container, configName, configDirectory); - container.RegisterInstance(service, IfAlreadyRegistered.Replace); - container.RegisterInstance(service, IfAlreadyRegistered.Replace); - container.AddToRegisterTypedList( - new ServiceRegistrationData(typeof(IConfigManagerService), typeof(ConfigManagerService), -1000) - ); - - return container; - } - /// /// Registers the default SquidStd core configuration sections. /// /// The same container for chaining. public IContainer RegisterDefaultCoreConfigSections() { - container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); - container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); + container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); return container; } @@ -82,9 +82,7 @@ public IContainer RegisterDefaultCoreConfigSections() /// /// The same container for chaining. public IContainer RegisterEventBusService() - { - return container.RegisterStdService(-1); - } + => container.RegisterStdService(-1); /// /// Registers the default job system service in the container. @@ -92,7 +90,7 @@ public IContainer RegisterEventBusService() /// The same container for chaining. public IContainer RegisterJobSystemService() { - container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); + container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); return container.RegisterStdService(-1); } @@ -102,9 +100,7 @@ public IContainer RegisterJobSystemService() /// /// The same container for chaining. public IContainer RegisterMainThreadDispatcherService() - { - return container.RegisterStdService(-1); - } + => container.RegisterStdService(-1); /// /// Registers the default timer wheel service in the container. @@ -112,7 +108,7 @@ public IContainer RegisterMainThreadDispatcherService() /// The same container for chaining. public IContainer RegisterTimerWheelService() { - container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); return container.RegisterStdService(-1); } diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index 18b80a19..b1221ced 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -66,7 +66,8 @@ public void Load() var entry = entries[i]; var value = string.IsNullOrWhiteSpace(yaml) ? entry.CreateDefault() - : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? entry.CreateDefault(); + : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? + entry.CreateDefault(); _values[entry.ConfigType] = value; _container.RegisterInstance(entry.ConfigType, value, IfAlreadyRegistered.Replace); @@ -135,10 +136,12 @@ private List GetRegistrations() return []; } - return _container.Resolve>() + return + [ + .. _container.Resolve>() .OrderBy(entry => entry.Priority) .ThenBy(entry => entry.SectionName, StringComparer.Ordinal) - .ToList(); + ]; } private static string ResolveConfigPath(string configName, string configDirectory) diff --git a/src/SquidStd.Services.Core/Services/EventBusService.cs b/src/SquidStd.Services.Core/Services/EventBusService.cs index 69ca6164..1f3d5a3c 100644 --- a/src/SquidStd.Services.Core/Services/EventBusService.cs +++ b/src/SquidStd.Services.Core/Services/EventBusService.cs @@ -21,7 +21,7 @@ public sealed class EventBusService : IEventBus, IDisposable public EventBusService() { _dispatches = Channel.CreateUnbounded( - new UnboundedChannelOptions + new() { SingleReader = true, SingleWriter = false @@ -30,20 +30,52 @@ public EventBusService() _dispatcher = Task.Run(ProcessDispatchesAsync); } - /// - public void RegisterListener(ISyncEventListener listener) - where TEvent : IEvent + private sealed class EventDispatch { - ArgumentNullException.ThrowIfNull(listener); - AddListener(_syncListeners, listener); + private readonly CancellationToken _cancellationToken; + private readonly Func _dispatch; + private readonly TaskCompletionSource _completion; + + public Task Completion => _completion.Task; + + public EventDispatch(Func dispatch, CancellationToken cancellationToken) + { + _dispatch = dispatch; + _cancellationToken = cancellationToken; + _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public async Task ExecuteAsync() + { + try + { + await _dispatch(); + _completion.TrySetResult(); + } + catch (OperationCanceledException) + { + _completion.TrySetCanceled(_cancellationToken); + } + catch (Exception exception) + { + _completion.TrySetException(exception); + } + } } - /// - public void RegisterAsyncListener(IAsyncEventListener listener) - where TEvent : IEvent + /// + /// Stops the internal dispatcher. + /// + public void Dispose() { - ArgumentNullException.ThrowIfNull(listener); - AddListener(_asyncListeners, listener); + if (_disposed) + { + return; + } + + _disposed = true; + _dispatches.Writer.TryComplete(); + _dispatcher.GetAwaiter().GetResult(); } /// @@ -93,19 +125,20 @@ public async Task PublishAsync(TEvent eventData, CancellationToken cance await dispatch.Completion; } - /// - /// Stops the internal dispatcher. - /// - public void Dispose() + /// + public void RegisterAsyncListener(IAsyncEventListener listener) + where TEvent : IEvent { - if (_disposed) - { - return; - } + ArgumentNullException.ThrowIfNull(listener); + AddListener(_asyncListeners, listener); + } - _disposed = true; - _dispatches.Writer.TryComplete(); - _dispatcher.GetAwaiter().GetResult(); + /// + public void RegisterListener(ISyncEventListener listener) + where TEvent : IEvent + { + ArgumentNullException.ThrowIfNull(listener); + AddListener(_syncListeners, listener); } private void AddListener(Dictionary> listenersByType, object listener) @@ -127,6 +160,16 @@ private void AddListener(Dictionary> listenersByType, } } + private void Enqueue(EventDispatch dispatch) + { + ThrowIfDisposed(); + + if (!_dispatches.Writer.TryWrite(dispatch)) + { + throw new ObjectDisposedException(nameof(EventBusService)); + } + } + private TListener[] GetListeners(Dictionary> listenersByType) where TEvent : IEvent where TListener : class @@ -144,16 +187,6 @@ private TListener[] GetListeners(Dictionary _dispatch; - private readonly TaskCompletionSource _completion; - - public Task Completion => _completion.Task; - - public EventDispatch(Func dispatch, CancellationToken cancellationToken) - { - _dispatch = dispatch; - _cancellationToken = cancellationToken; - _completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - - public async Task ExecuteAsync() - { - try - { - await _dispatch(); - _completion.TrySetResult(); - } - catch (OperationCanceledException) - { - _completion.TrySetCanceled(_cancellationToken); - } - catch (Exception exception) - { - _completion.TrySetException(exception); - } - } - } } diff --git a/src/SquidStd.Services.Core/Services/JobSystemService.cs b/src/SquidStd.Services.Core/Services/JobSystemService.cs index 5b3f6490..95253075 100644 --- a/src/SquidStd.Services.Core/Services/JobSystemService.cs +++ b/src/SquidStd.Services.Core/Services/JobSystemService.cs @@ -33,7 +33,7 @@ public JobSystemService(JobsConfig config) _config = config; WorkerCount = ResolveWorkerCount(config.WorkerThreadCount); _channel = Channel.CreateUnbounded( - new UnboundedChannelOptions + new() { SingleReader = false, SingleWriter = false @@ -187,6 +187,27 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } + private void Enqueue(JobItem item) + { + Interlocked.Increment(ref _pendingCount); + + if (!_channel.Writer.TryWrite(item)) + { + Interlocked.Decrement(ref _pendingCount); + + throw new ObjectDisposedException(nameof(JobSystemService)); + } + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs args) + { + _logger.Error(args.Exception, "Unobserved task exception"); + args.SetObserved(); + } + + private static int ResolveWorkerCount(int configured) + => configured > 0 ? configured : Math.Max(1, Environment.ProcessorCount - 1); + private void Stop(CancellationToken cancellationToken) { if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) @@ -234,27 +255,6 @@ private void Stop(CancellationToken cancellationToken) _shutdownCts.Dispose(); } - private void Enqueue(JobItem item) - { - Interlocked.Increment(ref _pendingCount); - - if (!_channel.Writer.TryWrite(item)) - { - Interlocked.Decrement(ref _pendingCount); - - throw new ObjectDisposedException(nameof(JobSystemService)); - } - } - - private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs args) - { - _logger.Error(args.Exception, "Unobserved task exception"); - args.SetObserved(); - } - - private static int ResolveWorkerCount(int configured) - => configured > 0 ? configured : Math.Max(1, Environment.ProcessorCount - 1); - private void ThrowIfDisposed() { if (Volatile.Read(ref _disposed) != 0) diff --git a/src/SquidStd.Services.Core/Services/TimerWheelService.cs b/src/SquidStd.Services.Core/Services/TimerWheelService.cs index 41c365cc..a7fc3f15 100644 --- a/src/SquidStd.Services.Core/Services/TimerWheelService.cs +++ b/src/SquidStd.Services.Core/Services/TimerWheelService.cs @@ -53,7 +53,7 @@ public TimerWheelService(TimerWheelConfig config) for (var i = 0; i < _wheel.Length; i++) { - _wheel[i] = new LinkedList(); + _wheel[i] = new(); } } diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index cfa54a83..fff271c4 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -7,13 +7,13 @@ - - + + - - + + diff --git a/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs b/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs index c0d785c1..3e52a88d 100644 --- a/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs +++ b/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs @@ -6,42 +6,42 @@ namespace SquidStd.Tests.Abstractions; public class AddTypedListMethodExtensionTests { [Fact] - public void AddToRegisterTypedList_FirstEntry_RegistersNewList() + public void AddToRegisterTypedList_DistinctTypes_RegisterSeparateLists() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.AddToRegisterTypedList("a"); + container.AddToRegisterTypedList("text"); + container.AddToRegisterTypedList(42); - Assert.Equal(["a"], container.Resolve>()); + Assert.Equal(["text"], container.Resolve>()); + Assert.Equal([42], container.Resolve>()); } [Fact] - public void AddToRegisterTypedList_MultipleEntries_AppendsToSameList() + public void AddToRegisterTypedList_FirstEntry_RegistersNewList() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.AddToRegisterTypedList("a"); - container.AddToRegisterTypedList("b"); - Assert.Equal(["a", "b"], container.Resolve>()); + Assert.Equal(["a"], container.Resolve>()); } [Fact] - public void AddToRegisterTypedList_DistinctTypes_RegisterSeparateLists() + public void AddToRegisterTypedList_MultipleEntries_AppendsToSameList() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.AddToRegisterTypedList("text"); - container.AddToRegisterTypedList(42); + container.AddToRegisterTypedList("a"); + container.AddToRegisterTypedList("b"); - Assert.Equal(["text"], container.Resolve>()); - Assert.Equal([42], container.Resolve>()); + Assert.Equal(["a", "b"], container.Resolve>()); } [Fact] public void AddToRegisterTypedList_ReturnsSameContainerForChaining() { - using var container = new DryIoc.Container(); + using var container = new Container(); var result = container.AddToRegisterTypedList(1); diff --git a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs b/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs index 34d613b5..7b01f332 100644 --- a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs +++ b/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs @@ -6,16 +6,12 @@ namespace SquidStd.Tests.Abstractions; public class ConfigRegistrationDataTests { [Fact] - public void Ctor_InvalidSectionName_Throws() - => Assert.Throws( - () => new ConfigRegistrationData(string.Empty, typeof(TestConfig), () => new TestConfig()) - ); + public void CreateDefault_IncompatibleFactory_Throws() + { + var entry = new ConfigRegistrationData("test", typeof(TestConfig), () => new()); - [Fact] - public void Ctor_NullType_Throws() - => Assert.Throws( - () => new ConfigRegistrationData("test", null!, () => new TestConfig()) - ); + Assert.Throws(() => entry.CreateDefault()); + } [Fact] public void CreateDefault_UsesFactory() @@ -24,7 +20,7 @@ public void CreateDefault_UsesFactory() "test", typeof(TestConfig), () => new TestConfig { Name = "default", Count = 7 }, - priority: 3 + 3 ); var config = Assert.IsType(entry.CreateDefault()); @@ -37,10 +33,12 @@ public void CreateDefault_UsesFactory() } [Fact] - public void CreateDefault_IncompatibleFactory_Throws() - { - var entry = new ConfigRegistrationData("test", typeof(TestConfig), () => new object()); + public void Ctor_InvalidSectionName_Throws() + => Assert.Throws( + () => new ConfigRegistrationData(string.Empty, typeof(TestConfig), () => new TestConfig()) + ); - Assert.Throws(() => entry.CreateDefault()); - } + [Fact] + public void Ctor_NullType_Throws() + => Assert.Throws(() => new ConfigRegistrationData("test", null!, () => new TestConfig())); } diff --git a/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs index 4d7f6b50..cb169580 100644 --- a/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs +++ b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs @@ -10,12 +10,12 @@ public class RegisterConfigSectionExtensionTests [Fact] public void RegisterConfigSection_AddsRegistrationData() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.RegisterConfigSection( + container.RegisterConfigSection( "test", static () => new TestConfig { Name = "default", Count = 2 }, - priority: 4 + 4 ); var entry = Assert.Single(container.Resolve>()); @@ -29,48 +29,44 @@ public void RegisterConfigSection_AddsRegistrationData() Assert.False(container.IsRegistered()); } - [Fact] - public void RegisterConfigSection_SameTypeAndSection_IsIdempotent() - { - using var container = new DryIoc.Container(); - - container.RegisterConfigSection("test"); - container.RegisterConfigSection("test"); - - Assert.Single(container.Resolve>()); - } - [Fact] public void RegisterConfigSection_DuplicateSectionWithDifferentType_Throws() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterConfigSection("test"); - Assert.Throws( - () => container.RegisterConfigSection("test") - ); + Assert.Throws(() => container.RegisterConfigSection("test")); } [Fact] public void RegisterConfigSection_DuplicateTypeWithDifferentSection_Throws() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterConfigSection("first"); - Assert.Throws( - () => container.RegisterConfigSection("second") - ); + Assert.Throws(() => container.RegisterConfigSection("second")); } [Fact] public void RegisterConfigSection_ReturnsSameContainerForChaining() { - using var container = new DryIoc.Container(); + using var container = new Container(); var result = container.RegisterConfigSection("test"); Assert.Same(container, result); } + + [Fact] + public void RegisterConfigSection_SameTypeAndSection_IsIdempotent() + { + using var container = new Container(); + + container.RegisterConfigSection("test"); + container.RegisterConfigSection("test"); + + Assert.Single(container.Resolve>()); + } } diff --git a/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs b/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs index d8f519af..6b6bc5db 100644 --- a/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs +++ b/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs @@ -9,56 +9,56 @@ namespace SquidStd.Tests.Abstractions; public class RegisterStdServiceExtensionTests { [Fact] - public void RegisterStdService_ResolvesImplementation() + public void RegisterStdService_AddsRegistrationDataWithPriority() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.RegisterStdService(); + container.RegisterStdService(5); - Assert.IsType(container.Resolve()); + var entry = Assert.Single(container.Resolve>()); + Assert.Equal(typeof(ISquidStdService), entry.ServiceType); + Assert.Equal(typeof(FakeStdService), entry.ImplementationType); + Assert.Equal(5, entry.Priority); } [Fact] - public void RegisterStdService_RegistersAsSingleton() + public void RegisterStdService_DefaultPriority_IsZero() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterStdService(); - var first = container.Resolve(); - var second = container.Resolve(); - - Assert.Same(first, second); + var entry = Assert.Single(container.Resolve>()); + Assert.Equal(0, entry.Priority); } [Fact] - public void RegisterStdService_AddsRegistrationDataWithPriority() + public void RegisterStdService_RegistersAsSingleton() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.RegisterStdService(5); + container.RegisterStdService(); - var entry = Assert.Single(container.Resolve>()); - Assert.Equal(typeof(ISquidStdService), entry.ServiceType); - Assert.Equal(typeof(FakeStdService), entry.ImplementationType); - Assert.Equal(5, entry.Priority); + var first = container.Resolve(); + var second = container.Resolve(); + + Assert.Same(first, second); } [Fact] - public void RegisterStdService_DefaultPriority_IsZero() + public void RegisterStdService_ResolvesImplementation() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterStdService(); - var entry = Assert.Single(container.Resolve>()); - Assert.Equal(0, entry.Priority); + Assert.IsType(container.Resolve()); } [Fact] public void RegisterStdService_ReturnsSameContainerForChaining() { - using var container = new DryIoc.Container(); + using var container = new Container(); var result = container.RegisterStdService(); diff --git a/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs b/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs index 38882865..5d1cef9c 100644 --- a/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs +++ b/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs @@ -4,6 +4,14 @@ namespace SquidStd.Tests.Abstractions; public class ServiceRegistrationDataTests { + [Fact] + public void Constructor_DefaultPriority_IsZero() + { + var data = new ServiceRegistrationData(typeof(IDisposable), typeof(string)); + + Assert.Equal(0, data.Priority); + } + [Fact] public void Constructor_SetsProperties() { @@ -15,11 +23,12 @@ public void Constructor_SetsProperties() } [Fact] - public void Constructor_DefaultPriority_IsZero() + public void Records_WithDifferentPriority_AreNotEqual() { - var data = new ServiceRegistrationData(typeof(IDisposable), typeof(string)); + var first = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 1); + var second = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 2); - Assert.Equal(0, data.Priority); + Assert.NotEqual(first, second); } [Fact] @@ -30,13 +39,4 @@ public void Records_WithSameValues_AreEqual() Assert.Equal(first, second); } - - [Fact] - public void Records_WithDifferentPriority_AreNotEqual() - { - var first = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 1); - var second = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 2); - - Assert.NotEqual(first, second); - } } diff --git a/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs b/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs index 5073dc4a..5a242bd7 100644 --- a/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs +++ b/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs @@ -19,12 +19,12 @@ public void Constructor_CreatesRootAndConfiguredSubdirectories() } [Fact] - public void GetPath_String_ReturnsSnakeCasedPath() + public void EnumIndexer_ResolvesPathFromEnumName() { using var temp = new TempDirectory(); - var config = new DirectoriesConfig(temp.Combine("app"), ["Logs"]); + var config = new DirectoriesConfig(temp.Combine("app"), []); - Assert.Equal(Path.Combine(config.Root, "logs"), config.GetPath("Logs")); + Assert.Equal(Path.Combine(config.Root, "plugins"), config[TestDirectoryType.Plugins]); } [Fact] @@ -40,30 +40,30 @@ public void GetPath_CreatesMissingDirectoryOnDemand() } [Fact] - public void StringIndexer_ReturnsSamePathAsGetPath() + public void GetPath_String_ReturnsSnakeCasedPath() { using var temp = new TempDirectory(); var config = new DirectoriesConfig(temp.Combine("app"), ["Logs"]); - Assert.Equal(config.GetPath("Logs"), config["Logs"]); + Assert.Equal(Path.Combine(config.Root, "logs"), config.GetPath("Logs")); } [Fact] - public void EnumIndexer_ResolvesPathFromEnumName() + public void GetPathGeneric_ResolvesPathFromEnumValue() { using var temp = new TempDirectory(); var config = new DirectoriesConfig(temp.Combine("app"), []); - Assert.Equal(Path.Combine(config.Root, "plugins"), config[TestDirectoryType.Plugins]); + Assert.Equal(Path.Combine(config.Root, "config_files"), config.GetPath(TestDirectoryType.ConfigFiles)); } [Fact] - public void GetPathGeneric_ResolvesPathFromEnumValue() + public void StringIndexer_ReturnsSamePathAsGetPath() { using var temp = new TempDirectory(); - var config = new DirectoriesConfig(temp.Combine("app"), []); + var config = new DirectoriesConfig(temp.Combine("app"), ["Logs"]); - Assert.Equal(Path.Combine(config.Root, "config_files"), config.GetPath(TestDirectoryType.ConfigFiles)); + Assert.Equal(config.GetPath("Logs"), config["Logs"]); } [Fact] diff --git a/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs b/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs index 1f5d87c3..7cd656a5 100644 --- a/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs +++ b/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs @@ -4,6 +4,10 @@ namespace SquidStd.Tests.Extensions.Directories; public class DirectoriesExtensionTests { + [Theory, InlineData(""), InlineData(" "), InlineData(null)] + public void ResolvePathAndEnvs_NullOrWhitespace_ReturnsNull(string? path) + => Assert.Null(path!.ResolvePathAndEnvs()); + [Fact] public void ResolvePathAndEnvs_TildePrefix_ExpandsToUserProfile() { @@ -13,11 +17,4 @@ public void ResolvePathAndEnvs_TildePrefix_ExpandsToUserProfile() Assert.Equal(home + "/sub", result); } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void ResolvePathAndEnvs_NullOrWhitespace_ReturnsNull(string? path) - => Assert.Null(path!.ResolvePathAndEnvs()); } diff --git a/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs b/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs index 7c9e7c8b..1a408dfc 100644 --- a/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs +++ b/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs @@ -6,6 +6,10 @@ public class EnvExtensionsTests { private const string TestVariable = "SQUIDSTD_UNIT_TEST_VAR"; + [Theory, InlineData(""), InlineData("no variables here")] + public void ExpandEnvironmentVariables_NoMatch_ReturnsInput(string input) + => Assert.Equal(input, input.ExpandEnvironmentVariables()); + [Fact] public void ExpandEnvironmentVariables_ReplacesDollarPrefixedVariable() { @@ -22,10 +26,4 @@ public void ExpandEnvironmentVariables_ReplacesDollarPrefixedVariable() Environment.SetEnvironmentVariable(TestVariable, null); } } - - [Theory] - [InlineData("")] - [InlineData("no variables here")] - public void ExpandEnvironmentVariables_NoMatch_ReturnsInput(string input) - => Assert.Equal(input, input.ExpandEnvironmentVariables()); } diff --git a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs index 03f75f09..2c86be72 100644 --- a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs +++ b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs @@ -6,18 +6,13 @@ namespace SquidStd.Tests.Extensions.Logger; public class LogLevelExtensionsTests { - [Theory] - [InlineData(LogLevelType.Trace, LogEventLevel.Verbose)] - [InlineData(LogLevelType.Debug, LogEventLevel.Debug)] - [InlineData(LogLevelType.Information, LogEventLevel.Information)] - [InlineData(LogLevelType.Warning, LogEventLevel.Warning)] - [InlineData(LogLevelType.Error, LogEventLevel.Error)] + [Theory, InlineData(LogLevelType.Trace, LogEventLevel.Verbose), InlineData(LogLevelType.Debug, LogEventLevel.Debug), + InlineData(LogLevelType.Information, LogEventLevel.Information), + InlineData(LogLevelType.Warning, LogEventLevel.Warning), InlineData(LogLevelType.Error, LogEventLevel.Error)] public void ToSerilogLogLevel_KnownLevels_MapsExpected(LogLevelType input, LogEventLevel expected) => Assert.Equal(expected, input.ToSerilogLogLevel()); - [Theory] - [InlineData(LogLevelType.None)] - [InlineData(LogLevelType.Critical)] + [Theory, InlineData(LogLevelType.None), InlineData(LogLevelType.Critical)] public void ToSerilogLogLevel_UnmappedLevels_FallsBackToInformation(LogLevelType input) => Assert.Equal(LogEventLevel.Information, input.ToSerilogLogLevel()); } diff --git a/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs b/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs index d3b66405..9aee1867 100644 --- a/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs +++ b/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs @@ -9,8 +9,8 @@ public void ToCamelCase_DelegatesToStringUtils() => Assert.Equal("helloWorld", "HelloWorld".ToCamelCase()); [Fact] - public void ToSnakeCase_DelegatesToStringUtils() - => Assert.Equal("hello_world", "HelloWorld".ToSnakeCase()); + public void ToDotCase_DelegatesToStringUtils() + => Assert.Equal("hello.world", "HelloWorld".ToDotCase()); [Fact] public void ToKebabCase_DelegatesToStringUtils() @@ -21,26 +21,26 @@ public void ToPascalCase_DelegatesToStringUtils() => Assert.Equal("HelloWorld", "hello_world".ToPascalCase()); [Fact] - public void ToSnakeCaseUpper_DelegatesToStringUtils() - => Assert.Equal("HELLO_WORLD", "HelloWorld".ToSnakeCaseUpper()); + public void ToPathCase_DelegatesToStringUtils() + => Assert.Equal("hello/world", "HelloWorld".ToPathCase()); [Fact] - public void ToTitleCase_DelegatesToStringUtils() - => Assert.Equal("Hello World", "hello_world".ToTitleCase()); + public void ToSentenceCase_DelegatesToStringUtils() + => Assert.Equal("Hello world", "hello world".ToSentenceCase()); [Fact] - public void ToTrainCase_DelegatesToStringUtils() - => Assert.Equal("Hello-World", "hello_world".ToTrainCase()); + public void ToSnakeCase_DelegatesToStringUtils() + => Assert.Equal("hello_world", "HelloWorld".ToSnakeCase()); [Fact] - public void ToDotCase_DelegatesToStringUtils() - => Assert.Equal("hello.world", "HelloWorld".ToDotCase()); + public void ToSnakeCaseUpper_DelegatesToStringUtils() + => Assert.Equal("HELLO_WORLD", "HelloWorld".ToSnakeCaseUpper()); [Fact] - public void ToPathCase_DelegatesToStringUtils() - => Assert.Equal("hello/world", "HelloWorld".ToPathCase()); + public void ToTitleCase_DelegatesToStringUtils() + => Assert.Equal("Hello World", "hello_world".ToTitleCase()); [Fact] - public void ToSentenceCase_DelegatesToStringUtils() - => Assert.Equal("Hello world", "hello world".ToSentenceCase()); + public void ToTrainCase_DelegatesToStringUtils() + => Assert.Equal("Hello-World", "hello_world".ToTrainCase()); } diff --git a/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs b/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs index 8c3ae252..bb476cef 100644 --- a/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs +++ b/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs @@ -15,20 +15,12 @@ public void GetRegisteredTypes_ReturnsAllSerializableTypes() } [Fact] - public void IsTypeRegistered_RegisteredType_ReturnsTrue() - => Assert.True(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(SampleDto))); - - [Fact] - public void IsTypeRegistered_UnregisteredType_ReturnsFalse() - => Assert.False(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(JsonContextTypeResolverTests))); - - [Fact] - public void GetTypeInfo_RegisteredType_ReturnsTypeInfo() + public void GetRegisteredTypesGeneric_FiltersByBaseType() { - var typeInfo = JsonContextTypeResolver.GetTypeInfo(TestJsonContext.Default); + var types = JsonContextTypeResolver.GetRegisteredTypes(TestJsonContext.Default).ToList(); - Assert.NotNull(typeInfo); - Assert.Equal(typeof(SampleDto), typeInfo.Type); + Assert.Contains(typeof(SampleDto), types); + Assert.DoesNotContain(typeof(OtherDto), types); } [Fact] @@ -41,11 +33,21 @@ public void GetRegisteredTypesWithInfo_ReturnsEntriesForAllTypes() } [Fact] - public void GetRegisteredTypesGeneric_FiltersByBaseType() + public void GetTypeInfo_RegisteredType_ReturnsTypeInfo() { - var types = JsonContextTypeResolver.GetRegisteredTypes(TestJsonContext.Default).ToList(); + var typeInfo = JsonContextTypeResolver.GetTypeInfo(TestJsonContext.Default); - Assert.Contains(typeof(SampleDto), types); - Assert.DoesNotContain(typeof(OtherDto), types); + Assert.NotNull(typeInfo); + Assert.Equal(typeof(SampleDto), typeInfo.Type); } + + [Fact] + public void IsTypeRegistered_RegisteredType_ReturnsTrue() + => Assert.True(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(SampleDto))); + + [Fact] + public void IsTypeRegistered_UnregisteredType_ReturnsFalse() + => Assert.False( + JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(JsonContextTypeResolverTests)) + ); } diff --git a/tests/SquidStd.Tests/Json/JsonUtilsTests.cs b/tests/SquidStd.Tests/Json/JsonUtilsTests.cs index c9b4e64e..47880313 100644 --- a/tests/SquidStd.Tests/Json/JsonUtilsTests.cs +++ b/tests/SquidStd.Tests/Json/JsonUtilsTests.cs @@ -14,19 +14,36 @@ public JsonUtilsTests() JsonUtils.RegisterJsonContext(TestJsonContext.Default); } + // Local type whose name ends with "Entity" to verify suffix stripping. + private sealed class UserEntity { } + [Fact] - public void SerializeDeserialize_RoundTrip_PreservesValues() + public void AddAndRemoveJsonConverter_MutatesConverterList() { - var original = new SampleDto { Name = "squid", Count = 42 }; - - var json = JsonUtils.Serialize(original); - var restored = JsonUtils.Deserialize(json); + try + { + JsonUtils.AddJsonConverter(new DummyGuidConverter()); + Assert.Contains(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); - Assert.NotNull(restored); - Assert.Equal(original.Name, restored.Name); - Assert.Equal(original.Count, restored.Count); + // Adding a second instance of the same type is ignored. + JsonUtils.AddJsonConverter(new DummyGuidConverter()); + Assert.Single(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); + } + finally + { + Assert.True(JsonUtils.RemoveJsonConverter()); + Assert.DoesNotContain(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); + } } + [Fact] + public void Deserialize_InvalidJson_ThrowsJsonException() + => Assert.Throws(() => JsonUtils.Deserialize("{ not valid")); + + [Theory, InlineData(""), InlineData(" ")] + public void Deserialize_NullOrWhitespace_Throws(string json) + => Assert.Throws(() => JsonUtils.Deserialize(json)); + [Fact] public void Deserialize_WithExplicitContext_ReturnsObject() { @@ -39,33 +56,10 @@ public void Deserialize_WithExplicitContext_ReturnsObject() } [Fact] - public void Serialize_NullObject_Throws() - => Assert.Throws(() => JsonUtils.Serialize(null!)); - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Deserialize_NullOrWhitespace_Throws(string json) - => Assert.Throws(() => JsonUtils.Deserialize(json)); - - [Fact] - public void Deserialize_InvalidJson_ThrowsJsonException() - => Assert.Throws(() => JsonUtils.Deserialize("{ not valid")); - - [Theory] - [InlineData("{\"a\":1}", true)] - [InlineData("[1,2,3]", true)] - [InlineData("not json", false)] - [InlineData("", false)] - public void IsValidJson_VariousInputs_ReturnsExpected(string json, bool expected) - => Assert.Equal(expected, JsonUtils.IsValidJson(json)); - - [Theory] - [InlineData("[1,2,3]", true)] - [InlineData("{\"a\":1}", false)] - [InlineData("invalid", false)] - public void IsArray_VariousInputs_ReturnsExpected(string json, bool expected) - => Assert.Equal(expected, JsonUtils.IsArray(json)); + public void DeserializeFromFile_MissingFile_Throws() + => Assert.Throws( + () => JsonUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json")) + ); [Fact] public void DeserializeOrDefault_EmptyJson_ReturnsDefault() @@ -86,9 +80,11 @@ public void DeserializeOrDefault_ValidJson_ReturnsObject() Assert.Equal("ok", result.Name); } - [Theory] - [InlineData("UserEntity", "user.schema.json")] - [InlineData("SampleDto", "sample_dto.schema.json")] + [Fact] + public void GetJsonConverters_ContainsDefaultEnumConverter() + => Assert.Contains(JsonUtils.GetJsonConverters(), converter => converter is JsonStringEnumConverter); + + [Theory, InlineData("UserEntity", "user.schema.json"), InlineData("SampleDto", "sample_dto.schema.json")] public void GetSchemaFileName_GeneratesSnakeCaseSchemaName(string typeName, string expected) { // Map the parameterized type name to a real type with the same Name. @@ -97,27 +93,30 @@ public void GetSchemaFileName_GeneratesSnakeCaseSchemaName(string typeName, stri Assert.Equal(expected, JsonUtils.GetSchemaFileName(type)); } + [Theory, InlineData("[1,2,3]", true), InlineData("{\"a\":1}", false), InlineData("invalid", false)] + public void IsArray_VariousInputs_ReturnsExpected(string json, bool expected) + => Assert.Equal(expected, JsonUtils.IsArray(json)); + + [Theory, InlineData("{\"a\":1}", true), InlineData("[1,2,3]", true), InlineData("not json", false), + InlineData("", false)] + public void IsValidJson_VariousInputs_ReturnsExpected(string json, bool expected) + => Assert.Equal(expected, JsonUtils.IsValidJson(json)); + [Fact] - public void GetJsonConverters_ContainsDefaultEnumConverter() - => Assert.Contains(JsonUtils.GetJsonConverters(), converter => converter is JsonStringEnumConverter); + public void Serialize_NullObject_Throws() + => Assert.Throws(() => JsonUtils.Serialize(null!)); [Fact] - public void AddAndRemoveJsonConverter_MutatesConverterList() + public void SerializeDeserialize_RoundTrip_PreservesValues() { - try - { - JsonUtils.AddJsonConverter(new DummyGuidConverter()); - Assert.Contains(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); + var original = new SampleDto { Name = "squid", Count = 42 }; - // Adding a second instance of the same type is ignored. - JsonUtils.AddJsonConverter(new DummyGuidConverter()); - Assert.Single(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); - } - finally - { - Assert.True(JsonUtils.RemoveJsonConverter()); - Assert.DoesNotContain(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); - } + var json = JsonUtils.Serialize(original); + var restored = JsonUtils.Deserialize(json); + + Assert.NotNull(restored); + Assert.Equal(original.Name, restored.Name); + Assert.Equal(original.Count, restored.Count); } [Fact] @@ -133,15 +132,4 @@ public void SerializeToFile_DeserializeFromFile_RoundTrips() Assert.Equal("file", restored.Name); Assert.Equal(9, restored.Count); } - - [Fact] - public void DeserializeFromFile_MissingFile_Throws() - => Assert.Throws( - () => JsonUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json")) - ); - - // Local type whose name ends with "Entity" to verify suffix stripping. - private sealed class UserEntity - { - } } diff --git a/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs b/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs index 3d48c2de..8e2aa0a5 100644 --- a/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs +++ b/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs @@ -11,13 +11,16 @@ public class EventSinkExtensionsTests public void EventSink_WiredIntoSerilog_RaisesEventsForLogs() { LogEventData? captured = null; - void Handler(object? sender, LogEventData data) => captured = data; + + void Handler(object? sender, LogEventData data) + => captured = data; EventSink.OnLogReceived += Handler; var logger = new LoggerConfiguration() - .WriteTo.EventSink() - .CreateLogger(); + .WriteTo + .EventSink() + .CreateLogger(); try { diff --git a/tests/SquidStd.Tests/Logging/EventSinkTests.cs b/tests/SquidStd.Tests/Logging/EventSinkTests.cs index b23bd7f2..1a0b90a8 100644 --- a/tests/SquidStd.Tests/Logging/EventSinkTests.cs +++ b/tests/SquidStd.Tests/Logging/EventSinkTests.cs @@ -9,29 +9,21 @@ namespace SquidStd.Tests.Logging; public class EventSinkTests { [Fact] - public void Emit_WithSubscriber_RaisesEventWithMappedData() + public void Emit_AfterClearSubscribers_DoesNotInvokeHandler() { - LogEventData? captured = null; - void Handler(object? sender, LogEventData data) => captured = data; + var invoked = false; + + void Handler(object? sender, LogEventData data) + => invoked = true; EventSink.OnLogReceived += Handler; + EventSink.ClearSubscribers(); try { - var logEvent = CreateLogEvent( - LogEventLevel.Warning, - "Hello {Name}", - new LogEventProperty("Name", new ScalarValue("World")), - new LogEventProperty("SourceContext", new ScalarValue("MyClass")) - ); - - new EventSink().Emit(logEvent); + new EventSink().Emit(CreateLogEvent(LogEventLevel.Information, "ignored")); - Assert.NotNull(captured); - Assert.Equal(LogEventLevel.Warning, captured.Level); - Assert.Contains("World", captured.Message); - Assert.Equal("World", captured.Properties["Name"]); - Assert.Equal("MyClass", captured.SourceContext); + Assert.False(invoked); } finally { @@ -41,19 +33,41 @@ public void Emit_WithSubscriber_RaisesEventWithMappedData() } [Fact] - public void Emit_AfterClearSubscribers_DoesNotInvokeHandler() + public void Emit_NoSubscribers_DoesNotThrow() { - var invoked = false; - void Handler(object? sender, LogEventData data) => invoked = true; + EventSink.ClearSubscribers(); + + var exception = Record.Exception(() => new EventSink().Emit(CreateLogEvent(LogEventLevel.Error, "no listeners"))); + + Assert.Null(exception); + } + + [Fact] + public void Emit_WithSubscriber_RaisesEventWithMappedData() + { + LogEventData? captured = null; + + void Handler(object? sender, LogEventData data) + => captured = data; EventSink.OnLogReceived += Handler; - EventSink.ClearSubscribers(); try { - new EventSink().Emit(CreateLogEvent(LogEventLevel.Information, "ignored")); + var logEvent = CreateLogEvent( + LogEventLevel.Warning, + "Hello {Name}", + new LogEventProperty("Name", new ScalarValue("World")), + new LogEventProperty("SourceContext", new ScalarValue("MyClass")) + ); - Assert.False(invoked); + new EventSink().Emit(logEvent); + + Assert.NotNull(captured); + Assert.Equal(LogEventLevel.Warning, captured.Level); + Assert.Contains("World", captured.Message); + Assert.Equal("World", captured.Properties["Name"]); + Assert.Equal("MyClass", captured.SourceContext); } finally { @@ -62,20 +76,10 @@ public void Emit_AfterClearSubscribers_DoesNotInvokeHandler() } } - [Fact] - public void Emit_NoSubscribers_DoesNotThrow() - { - EventSink.ClearSubscribers(); - - var exception = Record.Exception(() => new EventSink().Emit(CreateLogEvent(LogEventLevel.Error, "no listeners"))); - - Assert.Null(exception); - } - private static LogEvent CreateLogEvent(LogEventLevel level, string template, params LogEventProperty[] properties) { var parsedTemplate = new MessageTemplateParser().Parse(template); - return new LogEvent(DateTimeOffset.UtcNow, level, exception: null, parsedTemplate, properties); + return new(DateTimeOffset.UtcNow, level, null, parsedTemplate, properties); } } diff --git a/tests/SquidStd.Tests/Network/CircularBufferTests.cs b/tests/SquidStd.Tests/Network/CircularBufferTests.cs index e075cd09..8b1215db 100644 --- a/tests/SquidStd.Tests/Network/CircularBufferTests.cs +++ b/tests/SquidStd.Tests/Network/CircularBufferTests.cs @@ -5,6 +5,17 @@ namespace SquidStd.Tests.Network; public class CircularBufferTests { + [Fact] + public void Clear_ResetsSizeButKeepsCapacity() + { + var buffer = new CircularBuffer(4, [1, 2, 3]); + buffer.Clear(); + + Assert.Equal(0, buffer.Size); + Assert.True(buffer.IsEmpty); + Assert.Equal(4, buffer.Capacity); + } + [Fact] public void Constructor_NonPositiveCapacity_Throws() => Assert.Throws(() => new CircularBuffer(0)); @@ -23,53 +34,52 @@ public void Constructor_WithItems_PopulatesBuffer() } [Fact] - public void PushBack_WithinCapacity_AppendsInOrder() + public void Enumerator_YieldsLogicalOrder() { var buffer = new CircularBuffer(4); - buffer.PushBack(1); - buffer.PushBack(2); - buffer.PushBack(3); + buffer.PushBack(10); + buffer.PushBack(20); + buffer.PushBack(30); - Assert.Equal(3, buffer.Size); - Assert.Equal([1, 2, 3], buffer.ToArray()); - Assert.Equal(1, buffer.Front()); - Assert.Equal(3, buffer.Back()); + Assert.Equal([10, 20, 30], buffer.ToList()); } [Fact] - public void PushBack_WhenFull_DropsOldest() + public void Front_EmptyBuffer_Throws() + { + var buffer = new CircularBuffer(3); + + Assert.Throws(() => { buffer.Front(); }); + } + + [Fact] + public void Indexer_AccessesLogicalOrderAfterWrap() { var buffer = new CircularBuffer(3); buffer.PushBack(1); buffer.PushBack(2); buffer.PushBack(3); - buffer.PushBack(4); + buffer.PushBack(4); // wraps, drops 1 -> [2,3,4] - Assert.True(buffer.IsFull); - Assert.Equal([2, 3, 4], buffer.ToArray()); - Assert.Equal(2, buffer.Front()); - Assert.Equal(4, buffer.Back()); + Assert.Equal(2, buffer[0]); + Assert.Equal(3, buffer[1]); + Assert.Equal(4, buffer[2]); } [Fact] - public void PushFront_PrependsInReverseOrder() + public void Indexer_EmptyBuffer_Throws() { var buffer = new CircularBuffer(3); - buffer.PushFront(1); - buffer.PushFront(2); - buffer.PushFront(3); - Assert.Equal([3, 2, 1], buffer.ToArray()); + Assert.Throws(() => { _ = buffer[0]; }); } [Fact] - public void PopFront_RemovesFrontElement() + public void Indexer_OutOfRange_Throws() { - var buffer = new CircularBuffer(3, [1, 2, 3]); - buffer.PopFront(); + var buffer = new CircularBuffer(3, [1]); - Assert.Equal([2, 3], buffer.ToArray()); - Assert.Equal(2, buffer.Front()); + Assert.Throws(() => { _ = buffer[5]; }); } [Fact] @@ -83,82 +93,72 @@ public void PopBack_RemovesBackElement() } [Fact] - public void PushBackRange_LargerThanCapacity_KeepsLastElements() - { - var buffer = new CircularBuffer(4); - buffer.PushBackRange([1, 2, 3, 4, 5, 6]); - - Assert.True(buffer.IsFull); - Assert.Equal([3, 4, 5, 6], buffer.ToArray()); - } - - [Fact] - public void PushBackRange_WithinCapacity_Appends() - { - var buffer = new CircularBuffer(8); - buffer.PushBackRange([1, 2, 3]); - buffer.PushBackRange([4, 5]); - - Assert.Equal([1, 2, 3, 4, 5], buffer.ToArray()); - } - - [Fact] - public void Clear_ResetsSizeButKeepsCapacity() + public void PopFront_RemovesFrontElement() { - var buffer = new CircularBuffer(4, [1, 2, 3]); - buffer.Clear(); + var buffer = new CircularBuffer(3, [1, 2, 3]); + buffer.PopFront(); - Assert.Equal(0, buffer.Size); - Assert.True(buffer.IsEmpty); - Assert.Equal(4, buffer.Capacity); + Assert.Equal([2, 3], buffer.ToArray()); + Assert.Equal(2, buffer.Front()); } [Fact] - public void Indexer_AccessesLogicalOrderAfterWrap() + public void PushBack_WhenFull_DropsOldest() { var buffer = new CircularBuffer(3); buffer.PushBack(1); buffer.PushBack(2); buffer.PushBack(3); - buffer.PushBack(4); // wraps, drops 1 -> [2,3,4] + buffer.PushBack(4); - Assert.Equal(2, buffer[0]); - Assert.Equal(3, buffer[1]); - Assert.Equal(4, buffer[2]); + Assert.True(buffer.IsFull); + Assert.Equal([2, 3, 4], buffer.ToArray()); + Assert.Equal(2, buffer.Front()); + Assert.Equal(4, buffer.Back()); } [Fact] - public void Indexer_EmptyBuffer_Throws() + public void PushBack_WithinCapacity_AppendsInOrder() { - var buffer = new CircularBuffer(3); + var buffer = new CircularBuffer(4); + buffer.PushBack(1); + buffer.PushBack(2); + buffer.PushBack(3); - Assert.Throws(() => { _ = buffer[0]; }); + Assert.Equal(3, buffer.Size); + Assert.Equal([1, 2, 3], buffer.ToArray()); + Assert.Equal(1, buffer.Front()); + Assert.Equal(3, buffer.Back()); } [Fact] - public void Indexer_OutOfRange_Throws() + public void PushBackRange_LargerThanCapacity_KeepsLastElements() { - var buffer = new CircularBuffer(3, [1]); + var buffer = new CircularBuffer(4); + buffer.PushBackRange([1, 2, 3, 4, 5, 6]); - Assert.Throws(() => { _ = buffer[5]; }); + Assert.True(buffer.IsFull); + Assert.Equal([3, 4, 5, 6], buffer.ToArray()); } [Fact] - public void Front_EmptyBuffer_Throws() + public void PushBackRange_WithinCapacity_Appends() { - var buffer = new CircularBuffer(3); + var buffer = new CircularBuffer(8); + buffer.PushBackRange([1, 2, 3]); + buffer.PushBackRange([4, 5]); - Assert.Throws(() => { buffer.Front(); }); + Assert.Equal([1, 2, 3, 4, 5], buffer.ToArray()); } [Fact] - public void Enumerator_YieldsLogicalOrder() + public void PushFront_PrependsInReverseOrder() { - var buffer = new CircularBuffer(4); - buffer.PushBack(10); - buffer.PushBack(20); - buffer.PushBack(30); + var buffer = new CircularBuffer(3); + buffer.PushFront(1); + buffer.PushFront(2); + buffer.PushFront(3); - Assert.Equal([10, 20, 30], buffer.ToList()); + Assert.Equal([3, 2, 1], buffer.ToArray()); } } diff --git a/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs b/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs index 217553c3..c205684e 100644 --- a/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs +++ b/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Network.Client; using SquidStd.Network.Pipeline; using SquidStd.Tests.Support; @@ -7,23 +6,33 @@ namespace SquidStd.Tests.Network; public class NetMiddlewarePipelineTests { [Fact] - public async Task ExecuteAsync_NoMiddleware_ReturnsInputUnchanged() + public void AddMiddleware_RegistersMiddleware() { var pipeline = new NetMiddlewarePipeline(); + pipeline.AddMiddleware(new DroppingMiddleware()); - var result = await pipeline.ExecuteAsync(null, new byte[] { 1, 2 }, CancellationToken.None); - - Assert.Equal([1, 2], result.ToArray()); + Assert.True(pipeline.ContainsMiddleware()); } [Fact] - public async Task ExecuteAsync_RunsMiddlewareInRegistrationOrder() + public void ContainsMiddleware_ReflectsRegistration() { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0x01), new AppendingMiddleware(0x02)]); + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); - var result = await pipeline.ExecuteAsync(null, new byte[] { 0x00 }, CancellationToken.None); + Assert.True(pipeline.ContainsMiddleware()); + Assert.False(pipeline.ContainsMiddleware()); + } - Assert.Equal([0x00, 0x01, 0x02], result.ToArray()); + [Fact] + public async Task ExecuteAsync_CancelledToken_Throws() + { + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(null, new byte[] { 1 }, cts.Token) + ); } [Fact] @@ -37,31 +46,33 @@ public async Task ExecuteAsync_DroppingMiddleware_ShortCircuits() } [Fact] - public async Task ExecuteSendAsync_RunsMiddlewareInRegistrationOrder() + public async Task ExecuteAsync_NoMiddleware_ReturnsInputUnchanged() { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0xAA), new AppendingMiddleware(0xBB)]); + var pipeline = new NetMiddlewarePipeline(); - var result = await pipeline.ExecuteSendAsync(null, new byte[] { 0 }, CancellationToken.None); + var result = await pipeline.ExecuteAsync(null, new byte[] { 1, 2 }, CancellationToken.None); - Assert.Equal([0x00, 0xAA, 0xBB], result.ToArray()); + Assert.Equal([1, 2], result.ToArray()); } [Fact] - public void ContainsMiddleware_ReflectsRegistration() + public async Task ExecuteAsync_RunsMiddlewareInRegistrationOrder() { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0x01), new AppendingMiddleware(0x02)]); - Assert.True(pipeline.ContainsMiddleware()); - Assert.False(pipeline.ContainsMiddleware()); + var result = await pipeline.ExecuteAsync(null, new byte[] { 0x00 }, CancellationToken.None); + + Assert.Equal([0x00, 0x01, 0x02], result.ToArray()); } [Fact] - public void AddMiddleware_RegistersMiddleware() + public async Task ExecuteSendAsync_RunsMiddlewareInRegistrationOrder() { - var pipeline = new NetMiddlewarePipeline(); - pipeline.AddMiddleware(new DroppingMiddleware()); + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0xAA), new AppendingMiddleware(0xBB)]); - Assert.True(pipeline.ContainsMiddleware()); + var result = await pipeline.ExecuteSendAsync(null, new byte[] { 0 }, CancellationToken.None); + + Assert.Equal([0x00, 0xAA, 0xBB], result.ToArray()); } [Fact] @@ -73,16 +84,4 @@ public void RemoveMiddleware_RemovesRegisteredAndReportsResult() Assert.False(pipeline.ContainsMiddleware()); Assert.False(pipeline.RemoveMiddleware()); } - - [Fact] - public async Task ExecuteAsync_CancelledToken_Throws() - { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAsync( - async () => await pipeline.ExecuteAsync(null, new byte[] { 1 }, cts.Token) - ); - } } diff --git a/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs b/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs index 80dc069c..5f680b54 100644 --- a/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs +++ b/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs @@ -4,11 +4,7 @@ namespace SquidStd.Tests.Network; public class PacketExtensionsTests { - [Theory] - [InlineData(0x00, "0x00")] - [InlineData(0x0F, "0x0F")] - [InlineData(0xAB, "0xAB")] - [InlineData(0xFF, "0xFF")] + [Theory, InlineData(0x00, "0x00"), InlineData(0x0F, "0x0F"), InlineData(0xAB, "0xAB"), InlineData(0xFF, "0xFF")] public void ToPacketString_FormatsAsTwoDigitHex(byte opCode, string expected) => Assert.Equal(expected, opCode.ToPacketString()); } diff --git a/tests/SquidStd.Tests/Network/SessionManagerTests.cs b/tests/SquidStd.Tests/Network/SessionManagerTests.cs index bfaeb11d..535fa308 100644 --- a/tests/SquidStd.Tests/Network/SessionManagerTests.cs +++ b/tests/SquidStd.Tests/Network/SessionManagerTests.cs @@ -9,52 +9,79 @@ namespace SquidStd.Tests.Network; public class SessionManagerTests { - private static SquidTcpServer NewServer() - => new(new IPEndPoint(IPAddress.Loopback, 0)); + [Fact] + public async Task BroadcastAsync_SendsToAll_IsolatesFailures() + { + using var server = NewServer(); + using var manager = NewManager(server); - private static SessionManager NewManager(SquidTcpServer server) - => new(server, connection => $"state-{connection.SessionId}"); + var good = new FakeNetworkConnection(1); + var faulty = new FakeNetworkConnection(2) + { + SendCallback = _ => throw new InvalidOperationException("boom") + }; + manager.HandleConnected(good); + manager.HandleConnected(faulty); + + await manager.BroadcastAsync(new byte[] { 0xAB }, CancellationToken.None); + + Assert.Single(good.SentPayloads); + Assert.Equal([0xAB], good.SentPayloads[0]); + } [Fact] - public void HandleConnected_CreatesSessionAndRaisesEvent() + public async Task DisconnectAsync_KnownSession_ClosesConnection() { using var server = NewServer(); using var manager = NewManager(server); - Session? created = null; - manager.OnSessionCreated += (_, e) => created = e.Session; - - var connection = new FakeNetworkConnection(sessionId: 7); + var connection = new FakeNetworkConnection(6); manager.HandleConnected(connection); - Assert.Equal(1, manager.Count); - Assert.NotNull(created); - Assert.Equal(7, created!.SessionId); - Assert.Equal("state-7", created.State); - Assert.Same(connection, created.Connection); + await manager.DisconnectAsync(6, CancellationToken.None); + + Assert.Equal(1, connection.CloseCount); } [Fact] - public void TryGetSession_HitAndMiss() + public async Task DisconnectAsync_UnknownSession_IsNoOp() { using var server = NewServer(); using var manager = NewManager(server); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 3)); - Assert.True(manager.TryGetSession(3, out var session)); - Assert.Equal(3, session!.SessionId); - Assert.False(manager.TryGetSession(999, out var missing)); - Assert.Null(missing); + await manager.DisconnectAsync(999, CancellationToken.None); + + // No exception = pass. } [Fact] - public void Sessions_ReturnsSnapshotOfAll() + public void Dispose_ClearsSessionsAndIsIdempotent() + { + using var server = NewServer(); + var manager = NewManager(server); + manager.HandleConnected(new FakeNetworkConnection(1)); + + manager.Dispose(); + manager.Dispose(); // idempotent, no throw + + Assert.Equal(0, manager.Count); + } + + [Fact] + public void HandleConnected_CreatesSessionAndRaisesEvent() { using var server = NewServer(); using var manager = NewManager(server); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 1)); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 2)); + Session? created = null; + manager.OnSessionCreated += (_, e) => created = e.Session; - Assert.Equal(2, manager.Sessions.Count); + var connection = new FakeNetworkConnection(7); + manager.HandleConnected(connection); + + Assert.Equal(1, manager.Count); + Assert.NotNull(created); + Assert.Equal(7, created!.SessionId); + Assert.Equal("state-7", created.State); + Assert.Same(connection, created.Connection); } [Fact] @@ -62,7 +89,7 @@ public void HandleData_KnownSession_RaisesData() { using var server = NewServer(); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 5); + var connection = new FakeNetworkConnection(5); manager.HandleConnected(connection); SquidStdSessionDataEventArgs? received = null; @@ -83,7 +110,7 @@ public void HandleData_UnknownSession_DoesNotRaise() var raised = false; manager.OnSessionData += (_, _) => raised = true; - manager.HandleData(new FakeNetworkConnection(sessionId: 123), new byte[] { 1 }); + manager.HandleData(new FakeNetworkConnection(123), new byte[] { 1 }); Assert.False(raised); } @@ -93,7 +120,7 @@ public void HandleDisconnected_RemovesSessionAndRaisesOnce() { using var server = NewServer(); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 8); + var connection = new FakeNetworkConnection(8); manager.HandleConnected(connection); var removals = 0; @@ -108,114 +135,89 @@ public void HandleDisconnected_RemovesSessionAndRaisesOnce() } [Fact] - public async Task SendAsync_KnownSession_SendsToConnection() + public async Task Integration_LifecycleOverLoopback() { - using var server = NewServer(); + var timeout = TimeSpan.FromSeconds(5); + + await using var server = new SquidTcpServer(new(IPAddress.Loopback, 0)); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 4); - manager.HandleConnected(connection); - await manager.SendAsync(4, new byte[] { 7, 8 }, CancellationToken.None); + var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var dataReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var removed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); + manager.OnSessionData += (_, e) => dataReceived.TrySetResult(e.Data.ToArray()); + manager.OnSessionRemoved += (_, e) => removed.TrySetResult(e.Session.SessionId); - Assert.Single(connection.SentPayloads); - Assert.Equal([7, 8], connection.SentPayloads[0]); - } + await server.StartAsync(CancellationToken.None); + var port = server.Port; - [Fact] - public async Task SendAsync_UnknownSession_IsNoOp() - { - using var server = NewServer(); - using var manager = NewManager(server); + var client = await SquidStdTcpClient.ConnectAsync(new(IPAddress.Loopback, port)); - await manager.SendAsync(999, new byte[] { 1 }, CancellationToken.None); - // No exception = pass. + var session = await created.Task.WaitAsync(timeout); + Assert.Equal(1, manager.Count); + + await client.SendAsync(new byte[] { 10, 20, 30 }, CancellationToken.None); + Assert.Equal([10, 20, 30], await dataReceived.Task.WaitAsync(timeout)); + + await client.DisposeAsync(); + var removedId = await removed.Task.WaitAsync(timeout); + + Assert.Equal(session.SessionId, removedId); } [Fact] - public async Task DisconnectAsync_KnownSession_ClosesConnection() + public async Task SendAsync_KnownSession_SendsToConnection() { using var server = NewServer(); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 6); + var connection = new FakeNetworkConnection(4); manager.HandleConnected(connection); - await manager.DisconnectAsync(6, CancellationToken.None); + await manager.SendAsync(4, new byte[] { 7, 8 }, CancellationToken.None); - Assert.Equal(1, connection.CloseCount); + Assert.Single(connection.SentPayloads); + Assert.Equal([7, 8], connection.SentPayloads[0]); } [Fact] - public async Task DisconnectAsync_UnknownSession_IsNoOp() + public async Task SendAsync_UnknownSession_IsNoOp() { using var server = NewServer(); using var manager = NewManager(server); - await manager.DisconnectAsync(999, CancellationToken.None); + await manager.SendAsync(999, new byte[] { 1 }, CancellationToken.None); + // No exception = pass. } [Fact] - public async Task BroadcastAsync_SendsToAll_IsolatesFailures() + public void Sessions_ReturnsSnapshotOfAll() { using var server = NewServer(); using var manager = NewManager(server); + manager.HandleConnected(new FakeNetworkConnection()); + manager.HandleConnected(new FakeNetworkConnection(2)); - var good = new FakeNetworkConnection(sessionId: 1); - var faulty = new FakeNetworkConnection(sessionId: 2) - { - SendCallback = _ => throw new InvalidOperationException("boom") - }; - manager.HandleConnected(good); - manager.HandleConnected(faulty); - - await manager.BroadcastAsync(new byte[] { 0xAB }, CancellationToken.None); - - Assert.Single(good.SentPayloads); - Assert.Equal([0xAB], good.SentPayloads[0]); + Assert.Equal(2, manager.Sessions.Count); } [Fact] - public void Dispose_ClearsSessionsAndIsIdempotent() + public void TryGetSession_HitAndMiss() { using var server = NewServer(); - var manager = NewManager(server); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 1)); - - manager.Dispose(); - manager.Dispose(); // idempotent, no throw - - Assert.Equal(0, manager.Count); - } - - [Fact] - public async Task Integration_LifecycleOverLoopback() - { - var timeout = TimeSpan.FromSeconds(5); - - await using var server = new SquidTcpServer(new IPEndPoint(IPAddress.Loopback, 0)); using var manager = NewManager(server); + manager.HandleConnected(new FakeNetworkConnection(3)); - var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - var dataReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var removed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); - manager.OnSessionData += (_, e) => dataReceived.TrySetResult(e.Data.ToArray()); - manager.OnSessionRemoved += (_, e) => removed.TrySetResult(e.Session.SessionId); - - await server.StartAsync(CancellationToken.None); - var port = server.Port; - - var client = await SquidStdTcpClient.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port)); - - var session = await created.Task.WaitAsync(timeout); - Assert.Equal(1, manager.Count); - - await client.SendAsync(new byte[] { 10, 20, 30 }, CancellationToken.None); - Assert.Equal([10, 20, 30], await dataReceived.Task.WaitAsync(timeout)); + Assert.True(manager.TryGetSession(3, out var session)); + Assert.Equal(3, session!.SessionId); + Assert.False(manager.TryGetSession(999, out var missing)); + Assert.Null(missing); + } - await client.DisposeAsync(); - var removedId = await removed.Task.WaitAsync(timeout); + private static SessionManager NewManager(SquidTcpServer server) + => new(server, connection => $"state-{connection.SessionId}"); - Assert.Equal(session.SessionId, removedId); - } + private static SquidTcpServer NewServer() + => new(new(IPAddress.Loopback, 0)); } diff --git a/tests/SquidStd.Tests/Network/SessionTests.cs b/tests/SquidStd.Tests/Network/SessionTests.cs index c79d55ba..5fc4b245 100644 --- a/tests/SquidStd.Tests/Network/SessionTests.cs +++ b/tests/SquidStd.Tests/Network/SessionTests.cs @@ -1,4 +1,5 @@ using System.Net; +using SquidStd.Network.Data.Events; using SquidStd.Network.Sessions; using SquidStd.Tests.Support; @@ -8,10 +9,22 @@ public class SessionTests { private static readonly DateTimeOffset CreatedAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + [Fact] + public async Task CloseAsync_DelegatesToConnection() + { + var connection = new FakeNetworkConnection(); + var session = new Session(1, connection, "s", CreatedAt); + + await session.CloseAsync(); + + Assert.Equal(1, connection.CloseCount); + Assert.False(session.IsConnected); + } + [Fact] public void Constructor_PopulatesProperties() { - var connection = new FakeNetworkConnection(sessionId: 42, remoteEndPoint: new IPEndPoint(IPAddress.Loopback, 7000)); + var connection = new FakeNetworkConnection(42, new IPEndPoint(IPAddress.Loopback, 7000)); var session = new Session(42, connection, "state", CreatedAt); Assert.Equal(42, session.SessionId); @@ -35,33 +48,21 @@ public async Task SendAsync_DelegatesToConnection() } [Fact] - public async Task CloseAsync_DelegatesToConnection() - { - var connection = new FakeNetworkConnection(); - var session = new Session(1, connection, "s", CreatedAt); - - await session.CloseAsync(); - - Assert.Equal(1, connection.CloseCount); - Assert.False(session.IsConnected); - } - - [Fact] - public void SessionEventArgs_CarriesSession() + public void SessionDataEventArgs_CarriesSessionAndData() { var session = new Session(1, new FakeNetworkConnection(), "s", CreatedAt); - var args = new SquidStd.Network.Data.Events.SquidStdSessionEventArgs(session); + var args = new SquidStdSessionDataEventArgs(session, new byte[] { 9 }); Assert.Same(session, args.Session); + Assert.Equal([9], args.Data.ToArray()); } [Fact] - public void SessionDataEventArgs_CarriesSessionAndData() + public void SessionEventArgs_CarriesSession() { var session = new Session(1, new FakeNetworkConnection(), "s", CreatedAt); - var args = new SquidStd.Network.Data.Events.SquidStdSessionDataEventArgs(session, new byte[] { 9 }); + var args = new SquidStdSessionEventArgs(session); Assert.Same(session, args.Session); - Assert.Equal([9], args.Data.ToArray()); } } diff --git a/tests/SquidStd.Tests/Network/SpanReaderTests.cs b/tests/SquidStd.Tests/Network/SpanReaderTests.cs index e633c180..0c420d51 100644 --- a/tests/SquidStd.Tests/Network/SpanReaderTests.cs +++ b/tests/SquidStd.Tests/Network/SpanReaderTests.cs @@ -5,39 +5,20 @@ namespace SquidStd.Tests.Network; public class SpanReaderTests { [Fact] - public void ReadInt32_ReadsBigEndian() - { - var reader = new SpanReader([0x01, 0x02, 0x03, 0x04]); - - Assert.Equal(0x01020304, reader.ReadInt32()); - Assert.Equal(0, reader.Remaining); - } - - [Fact] - public void ReadInt32LE_ReadsLittleEndian() - { - var reader = new SpanReader([0x04, 0x03, 0x02, 0x01]); - - Assert.Equal(0x01020304, reader.ReadInt32LE()); - } - - [Fact] - public void ReadByte_AdvancesPosition() + public void ReadAscii_FixedLength_ReadsExactBytes() { - var reader = new SpanReader([0xAA, 0xBB]); + var reader = new SpanReader([(byte)'h', (byte)'i', (byte)'!']); - Assert.Equal(0xAA, reader.ReadByte()); - Assert.Equal(1, reader.Position); - Assert.Equal(0xBB, reader.ReadByte()); + Assert.Equal("hi", reader.ReadAscii(2)); + Assert.Equal(1, reader.Remaining); } [Fact] - public void ReadBytes_ReturnsRequestedSlice() + public void ReadAscii_NullTerminated_StopsAtTerminator() { - var reader = new SpanReader([1, 2, 3, 4, 5]); + var reader = new SpanReader([(byte)'h', (byte)'i', 0]); - Assert.Equal([1, 2, 3], reader.ReadBytes(3)); - Assert.Equal(2, reader.Remaining); + Assert.Equal("hi", reader.ReadAscii()); } [Fact] @@ -50,30 +31,13 @@ public void ReadBoolean_NonZeroIsTrue() } [Fact] - public void Seek_FromBegin_SetsPosition() - { - var reader = new SpanReader([1, 2, 3, 4]); - reader.Seek(2, SeekOrigin.Begin); - - Assert.Equal(2, reader.Position); - Assert.Equal(3, reader.ReadByte()); - } - - [Fact] - public void ReadAscii_FixedLength_ReadsExactBytes() - { - var reader = new SpanReader([(byte)'h', (byte)'i', (byte)'!']); - - Assert.Equal("hi", reader.ReadAscii(2)); - Assert.Equal(1, reader.Remaining); - } - - [Fact] - public void ReadAscii_NullTerminated_StopsAtTerminator() + public void ReadByte_AdvancesPosition() { - var reader = new SpanReader([(byte)'h', (byte)'i', 0]); + var reader = new SpanReader([0xAA, 0xBB]); - Assert.Equal("hi", reader.ReadAscii()); + Assert.Equal(0xAA, reader.ReadByte()); + Assert.Equal(1, reader.Position); + Assert.Equal(0xBB, reader.ReadByte()); } [Fact] @@ -96,6 +60,15 @@ public void ReadByte_BeyondEnd_Throws() Assert.True(threw); } + [Fact] + public void ReadBytes_ReturnsRequestedSlice() + { + var reader = new SpanReader([1, 2, 3, 4, 5]); + + Assert.Equal([1, 2, 3], reader.ReadBytes(3)); + Assert.Equal(2, reader.Remaining); + } + [Fact] public void ReadInt32_InsufficientData_Throws() { @@ -115,6 +88,23 @@ public void ReadInt32_InsufficientData_Throws() Assert.True(threw); } + [Fact] + public void ReadInt32_ReadsBigEndian() + { + var reader = new SpanReader([0x01, 0x02, 0x03, 0x04]); + + Assert.Equal(0x01020304, reader.ReadInt32()); + Assert.Equal(0, reader.Remaining); + } + + [Fact] + public void ReadInt32LE_ReadsLittleEndian() + { + var reader = new SpanReader([0x04, 0x03, 0x02, 0x01]); + + Assert.Equal(0x01020304, reader.ReadInt32LE()); + } + [Fact] public void RoundTrip_WithSpanWriter_PreservesValues() { @@ -131,4 +121,14 @@ public void RoundTrip_WithSpanWriter_PreservesValues() Assert.True(reader.ReadBoolean()); Assert.Equal("squid", reader.ReadAscii()); } + + [Fact] + public void Seek_FromBegin_SetsPosition() + { + var reader = new SpanReader([1, 2, 3, 4]); + reader.Seek(2, SeekOrigin.Begin); + + Assert.Equal(2, reader.Position); + Assert.Equal(3, reader.ReadByte()); + } } diff --git a/tests/SquidStd.Tests/Network/SpanWriterTests.cs b/tests/SquidStd.Tests/Network/SpanWriterTests.cs index 75c94117..26359d6e 100644 --- a/tests/SquidStd.Tests/Network/SpanWriterTests.cs +++ b/tests/SquidStd.Tests/Network/SpanWriterTests.cs @@ -5,21 +5,50 @@ namespace SquidStd.Tests.Network; public class SpanWriterTests { [Fact] - public void Write_Int32_WritesBigEndian() + public void NoResize_Overflow_Throws() { - using var writer = new SpanWriter(8); + var buffer = new byte[2]; + var writer = new SpanWriter(buffer); + + var threw = false; + + try + { + writer.Write(0x01020304); + } + catch (InvalidOperationException) + { + threw = true; + } + + Assert.True(threw); + } + + [Fact] + public void Resize_GrowsBeyondInitialCapacity() + { + using var writer = new SpanWriter(2, true); writer.Write(0x01020304); + writer.Write(0x05060708); - Assert.Equal([0x01, 0x02, 0x03, 0x04], writer.ToArray()); + Assert.Equal(8, writer.BytesWritten); } [Fact] - public void WriteLE_Int32_WritesLittleEndian() + public void ToArray_EmptyWriter_ReturnsEmpty() { using var writer = new SpanWriter(8); - writer.WriteLE(0x01020304); - Assert.Equal([0x04, 0x03, 0x02, 0x01], writer.ToArray()); + Assert.Empty(writer.ToArray()); + } + + [Fact] + public void Write_Int32_WritesBigEndian() + { + using var writer = new SpanWriter(8); + writer.Write(0x01020304); + + Assert.Equal([0x01, 0x02, 0x03, 0x04], writer.ToArray()); } [Fact] @@ -43,40 +72,11 @@ public void WriteAsciiNull_AppendsNullTerminator() } [Fact] - public void Resize_GrowsBeyondInitialCapacity() - { - using var writer = new SpanWriter(2, resize: true); - writer.Write(0x01020304); - writer.Write(0x05060708); - - Assert.Equal(8, writer.BytesWritten); - } - - [Fact] - public void NoResize_Overflow_Throws() - { - var buffer = new byte[2]; - var writer = new SpanWriter(buffer); - - var threw = false; - - try - { - writer.Write(0x01020304); - } - catch (InvalidOperationException) - { - threw = true; - } - - Assert.True(threw); - } - - [Fact] - public void ToArray_EmptyWriter_ReturnsEmpty() + public void WriteLE_Int32_WritesLittleEndian() { using var writer = new SpanWriter(8); + writer.WriteLE(0x01020304); - Assert.Empty(writer.ToArray()); + Assert.Equal([0x04, 0x03, 0x02, 0x01], writer.ToArray()); } } diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs index 6bc1e83c..e58b6dbf 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs @@ -7,11 +7,26 @@ public class SquidStdUdpClientTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + [Fact] + public async Task CloseAsync_RaisesDisconnectedOnceAndMarksDisconnected() + { + var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var disconnects = 0; + client.OnDisconnected += (_, _) => Interlocked.Increment(ref disconnects); + await client.StartAsync(CancellationToken.None); + + await client.CloseAsync(CancellationToken.None); + await client.CloseAsync(CancellationToken.None); + + Assert.False(client.IsConnected); + Assert.Equal(1, disconnects); + } + [Fact] public void Constructor_AssignsUniqueSessionIds() { - using var first = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - using var second = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var first = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + using var second = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); Assert.NotEqual(first.SessionId, second.SessionId); } @@ -19,7 +34,7 @@ public void Constructor_AssignsUniqueSessionIds() [Fact] public void Constructor_BindsLocalEndPoint() { - using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var local = Assert.IsType(client.LocalEndPoint); Assert.NotEqual(0, local.Port); @@ -31,15 +46,36 @@ public void RemoteEndPoint_ReflectsConfiguredDefault() { var remote = new IPEndPoint(IPAddress.Loopback, 9999); - using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0), remote); + using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0), remote); Assert.Equal(remote, client.RemoteEndPoint); } + [Fact] + public async Task SendAsync_UsesConfiguredDefaultRemote() + { + await using var receiver = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await receiver.StartAsync(CancellationToken.None); + + var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; + + await using var sender = new SquidStdUdpClient( + new(IPAddress.Loopback, 0), + new(IPAddress.Loopback, receiverPort) + ); + await sender.StartAsync(CancellationToken.None); + await sender.SendAsync(new byte[] { 9, 8, 7 }, CancellationToken.None); + + var payload = await received.Task.WaitAsync(Timeout); + Assert.Equal([9, 8, 7], payload); + } + [Fact] public async Task SendAsync_WithoutDefaultRemote_Throws() { - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); await Assert.ThrowsAsync( async () => await client.SendAsync(new byte[] { 1 }, CancellationToken.None) @@ -49,18 +85,18 @@ await Assert.ThrowsAsync( [Fact] public async Task SendToAsync_DeliversDatagramToPeer() { - await using var receiver = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var receiver = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await receiver.StartAsync(CancellationToken.None); var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; - await using var sender = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var sender = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); await sender.StartAsync(CancellationToken.None); await sender.SendToAsync( new byte[] { 1, 2, 3, 4 }, - new IPEndPoint(IPAddress.Loopback, receiverPort), + new(IPAddress.Loopback, receiverPort), CancellationToken.None ); @@ -68,46 +104,10 @@ await sender.SendToAsync( Assert.Equal([1, 2, 3, 4], payload); } - [Fact] - public async Task SendAsync_UsesConfiguredDefaultRemote() - { - await using var receiver = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); - await receiver.StartAsync(CancellationToken.None); - - var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; - - await using var sender = new SquidStdUdpClient( - new IPEndPoint(IPAddress.Loopback, 0), - new IPEndPoint(IPAddress.Loopback, receiverPort) - ); - await sender.StartAsync(CancellationToken.None); - await sender.SendAsync(new byte[] { 9, 8, 7 }, CancellationToken.None); - - var payload = await received.Task.WaitAsync(Timeout); - Assert.Equal([9, 8, 7], payload); - } - - [Fact] - public async Task CloseAsync_RaisesDisconnectedOnceAndMarksDisconnected() - { - var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - var disconnects = 0; - client.OnDisconnected += (_, _) => Interlocked.Increment(ref disconnects); - await client.StartAsync(CancellationToken.None); - - await client.CloseAsync(CancellationToken.None); - await client.CloseAsync(CancellationToken.None); - - Assert.False(client.IsConnected); - Assert.Equal(1, disconnects); - } - [Fact] public async Task StartAsync_RaisesConnected() { - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var connected = false; client.OnConnected += (_, _) => connected = true; diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs index cd3b6579..059b94fa 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs @@ -10,44 +10,28 @@ public class SquidStdUdpServerTests private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); [Fact] - public void ServerType_IsUdp() - { - using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); - - Assert.Equal(ServerType.UDP, server.ServerType); - } - - [Fact] - public async Task StartStop_TogglesIsRunning() + public async Task BindSingleInterface_HasOneListener() { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); - - Assert.False(server.IsRunning); + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); await server.StartAsync(CancellationToken.None); - Assert.True(server.IsRunning); - - await server.StopAsync(CancellationToken.None); - Assert.False(server.IsRunning); - // Start/Stop/Start cycle is supported. - await server.StartAsync(CancellationToken.None); - Assert.True(server.IsRunning); + Assert.Equal(1, server.ListenerCount); } [Fact] public async Task DefaultBehaviour_EchoesDatagramBackToSender() { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await client.StartAsync(CancellationToken.None); var payload = new byte[] { 1, 2, 3, 4, 5 }; - await client.SendToAsync(payload, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); + await client.SendToAsync(payload, new(IPAddress.Loopback, serverPort), CancellationToken.None); Assert.Equal(payload, await received.Task.WaitAsync(Timeout)); } @@ -55,29 +39,45 @@ public async Task DefaultBehaviour_EchoesDatagramBackToSender() [Fact] public async Task OnDatagram_CustomResponse_IsReturnedToSender() { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false) + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false) { OnDatagram = (_, _) => new byte[] { 0xFF, 0xFE } }; await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await client.StartAsync(CancellationToken.None); - await client.SendToAsync(new byte[] { 1 }, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); + await client.SendToAsync(new byte[] { 1 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); Assert.Equal([0xFF, 0xFE], await received.Task.WaitAsync(Timeout)); } [Fact] - public async Task BindSingleInterface_HasOneListener() + public void ServerType_IsUdp() + { + using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + + Assert.Equal(ServerType.UDP, server.ServerType); + } + + [Fact] + public async Task StartStop_TogglesIsRunning() { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + + Assert.False(server.IsRunning); await server.StartAsync(CancellationToken.None); + Assert.True(server.IsRunning); - Assert.Equal(1, server.ListenerCount); + await server.StopAsync(CancellationToken.None); + Assert.False(server.IsRunning); + + // Start/Stop/Start cycle is supported. + await server.StartAsync(CancellationToken.None); + Assert.True(server.IsRunning); } } diff --git a/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs b/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs index f5260f33..6b49f609 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs @@ -9,13 +9,8 @@ public void Data_IsEmptyByDefault() => Assert.Empty(new PluginContext().Data); [Fact] - public void GetData_ValueType_ReturnsStoredValue() - { - var context = new PluginContext(); - context.Data["count"] = 42; - - Assert.Equal(42, context.GetData("count")); - } + public void GetData_MissingKey_Throws() + => Assert.Throws(() => { _ = new PluginContext().GetData("missing"); }); [Fact] public void GetData_ReferenceType_ReturnsStoredValue() @@ -28,8 +23,13 @@ public void GetData_ReferenceType_ReturnsStoredValue() } [Fact] - public void GetData_MissingKey_Throws() - => Assert.Throws(() => { _ = new PluginContext().GetData("missing"); }); + public void GetData_ValueType_ReturnsStoredValue() + { + var context = new PluginContext(); + context.Data["count"] = 42; + + Assert.Equal(42, context.GetData("count")); + } [Fact] public void GetData_WrongType_Throws() diff --git a/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs b/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs index b21c3062..bbaa345e 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs @@ -11,28 +11,29 @@ public void Constructor_SetsRequiredProperties() { Id = "squidstd.weather", Name = "Weather", - Version = new Version(2, 1, 0), + Version = new(2, 1, 0), Author = "squid" }; Assert.Equal("squidstd.weather", metadata.Id); Assert.Equal("Weather", metadata.Name); - Assert.Equal(new Version(2, 1, 0), metadata.Version); + Assert.Equal(new(2, 1, 0), metadata.Version); Assert.Equal("squid", metadata.Author); } [Fact] - public void Description_DefaultsToNull() + public void Dependencies_CanBeProvided() { var metadata = new PluginMetadata { Id = "id", Name = "name", - Version = new Version(1, 0), - Author = "author" + Version = new(1, 0), + Author = "author", + Dependencies = ["squidstd.core", "squidstd.net"] }; - Assert.Null(metadata.Description); + Assert.Equal(["squidstd.core", "squidstd.net"], metadata.Dependencies); } [Fact] @@ -42,7 +43,7 @@ public void Dependencies_DefaultsToEmpty() { Id = "id", Name = "name", - Version = new Version(1, 0), + Version = new(1, 0), Author = "author" }; @@ -50,17 +51,16 @@ public void Dependencies_DefaultsToEmpty() } [Fact] - public void Dependencies_CanBeProvided() + public void Description_DefaultsToNull() { var metadata = new PluginMetadata { Id = "id", Name = "name", - Version = new Version(1, 0), - Author = "author", - Dependencies = ["squidstd.core", "squidstd.net"] + Version = new(1, 0), + Author = "author" }; - Assert.Equal(["squidstd.core", "squidstd.net"], metadata.Dependencies); + Assert.Null(metadata.Description); } } diff --git a/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs b/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs index 88257ea4..2fc16ed9 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs @@ -8,34 +8,34 @@ namespace SquidStd.Tests.PluginAbstractions; public class SquidStdPluginTests { [Fact] - public void Metadata_ExposesPluginIdentity() + public void Configure_ReceivesProvidedContext() { - ISquidStdPlugin plugin = new FakePlugin(); + using var container = new Container(); + var plugin = new FakePlugin(); + var context = new PluginContext(); - Assert.Equal("squidstd.fake", plugin.Metadata.Id); - Assert.Equal(new Version(1, 2, 3), plugin.Metadata.Version); + ((ISquidStdPlugin)plugin).Configure(container, context); + + Assert.Same(context, plugin.ReceivedContext); } [Fact] public void Configure_RegistersServicesIntoContainer() { - using var container = new DryIoc.Container(); + using var container = new Container(); var plugin = new FakePlugin(); - ((ISquidStdPlugin)plugin).Configure(container, new PluginContext()); + ((ISquidStdPlugin)plugin).Configure(container, new()); Assert.Same(plugin, container.Resolve()); } [Fact] - public void Configure_ReceivesProvidedContext() + public void Metadata_ExposesPluginIdentity() { - using var container = new DryIoc.Container(); - var plugin = new FakePlugin(); - var context = new PluginContext(); - - ((ISquidStdPlugin)plugin).Configure(container, context); + ISquidStdPlugin plugin = new FakePlugin(); - Assert.Same(context, plugin.ReceivedContext); + Assert.Equal("squidstd.fake", plugin.Metadata.Id); + Assert.Equal(new(1, 2, 3), plugin.Metadata.Version); } } diff --git a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs index 5962b734..db670d1a 100644 --- a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs @@ -9,22 +9,25 @@ namespace SquidStd.Tests.Services.Core; public class ConfigManagerServiceTests { [Fact] - public async Task StartAsync_MissingFile_CreatesDefaultFileAndRegistersSection() + public void ConfigPath_AppendsYamlExtension() + { + using var container = new Container(); + IConfigManagerService manager = new ConfigManagerService(container, "app", "/tmp/config"); + + Assert.Equal(Path.Combine("/tmp/config", "app.yaml"), manager.ConfigPath); + } + + [Fact] + public async Task GetConfig_ReturnsRegisteredSectionAfterStart() { using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + using var container = new Container(); + container.RegisterConfigSection("test"); IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); - var path = Path.Combine(temp.Path, "app.yaml"); - var config = container.Resolve(); - - Assert.True(File.Exists(path)); - Assert.Equal("default", config.Name); - Assert.Equal(3, config.Count); - Assert.Contains("test:", File.ReadAllText(path)); + Assert.Same(container.Resolve(), manager.GetConfig()); } [Fact] @@ -39,8 +42,8 @@ public async Task StartAsync_ExistingFile_LoadsAndRegistersSection() Count: 9 """ ); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + using var container = new Container(); + container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); @@ -50,6 +53,25 @@ public async Task StartAsync_ExistingFile_LoadsAndRegistersSection() Assert.Equal(9, config.Count); } + [Fact] + public async Task StartAsync_MissingFile_CreatesDefaultFileAndRegistersSection() + { + using var temp = new TempDirectory(); + using var container = new Container(); + container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); + + await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); + + var path = Path.Combine(temp.Path, "app.yaml"); + var config = container.Resolve(); + + Assert.True(File.Exists(path)); + Assert.Equal("default", config.Name); + Assert.Equal(3, config.Count); + Assert.Contains("test:", File.ReadAllText(path)); + } + [Fact] public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() { @@ -61,8 +83,8 @@ public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() Enabled: true """ ); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + using var container = new Container(); + container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); @@ -76,26 +98,4 @@ public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() Assert.Contains("test:", yaml); Assert.DoesNotContain("other:", yaml); } - - [Fact] - public async Task GetConfig_ReturnsRegisteredSectionAfterStart() - { - using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test"); - IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); - - await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); - - Assert.Same(container.Resolve(), manager.GetConfig()); - } - - [Fact] - public void ConfigPath_AppendsYamlExtension() - { - using var container = new DryIoc.Container(); - IConfigManagerService manager = new ConfigManagerService(container, "app", "/tmp/config"); - - Assert.Equal(Path.Combine("/tmp/config", "app.yaml"), manager.ConfigPath); - } } diff --git a/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs b/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs index 372bf475..af266259 100644 --- a/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs @@ -5,6 +5,78 @@ namespace SquidStd.Tests.Services.Core; public class EventBusServiceTests { + private sealed record TestEvent(string Payload) : IEvent; + + private sealed class SyncListener : ISyncEventListener + { + private readonly List _calls; + private readonly string _name; + + public TestEvent? LastEvent { get; private set; } + + public SyncListener(string name, List calls) + { + _name = name; + _calls = calls; + } + + public void Handle(TestEvent eventData) + { + LastEvent = eventData; + _calls.Add($"{_name}:{eventData.Payload}"); + } + } + + private sealed class ThrowingSyncListener : ISyncEventListener + { + private readonly InvalidOperationException _exception; + + public ThrowingSyncListener(InvalidOperationException exception) + { + _exception = exception; + } + + public void Handle(TestEvent eventData) + => throw _exception; + } + + private sealed class AsyncListener : IAsyncEventListener + { + private readonly List _calls; + private readonly string _name; + + public CancellationToken CancellationToken { get; private set; } + public TestEvent? LastEvent { get; private set; } + + public AsyncListener(string name, List calls) + { + _name = name; + _calls = calls; + } + + public async Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) + { + await Task.Yield(); + + CancellationToken = cancellationToken; + LastEvent = eventData; + _calls.Add($"{_name}:{eventData.Payload}"); + } + } + + private sealed class ThrowingAsyncListener : IAsyncEventListener + { + private readonly InvalidOperationException _exception; + + public ThrowingAsyncListener(InvalidOperationException exception) + { + _exception = exception; + } + + public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) + => throw _exception; + } + [Fact] public void Publish_NoSyncListeners_DoesNotThrow() { @@ -54,11 +126,26 @@ public async Task PublishAsync_NoAsyncListeners_Completes() using var eventBus = new EventBusService(); IEventBus bus = eventBus; - var exception = await Record.ExceptionAsync(() => bus.PublishAsync(new TestEvent("ignored"), CancellationToken.None)); + var exception = + await Record.ExceptionAsync(() => bus.PublishAsync(new TestEvent("ignored"), CancellationToken.None)); Assert.Null(exception); } + [Fact] + public async Task PublishAsync_PassesCancellationToken() + { + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + using var cancellationTokenSource = new CancellationTokenSource(); + var listener = new AsyncListener("listener", []); + bus.RegisterAsyncListener(listener); + + await bus.PublishAsync(new TestEvent("payload"), cancellationTokenSource.Token); + + Assert.Equal(cancellationTokenSource.Token, listener.CancellationToken); + } + [Fact] public async Task PublishAsync_RegisteredAsyncListeners_InvokesEachInRegistrationOrder() { @@ -78,20 +165,6 @@ public async Task PublishAsync_RegisteredAsyncListeners_InvokesEachInRegistratio Assert.Same(eventData, second.LastEvent); } - [Fact] - public async Task PublishAsync_PassesCancellationToken() - { - using var eventBus = new EventBusService(); - IEventBus bus = eventBus; - using var cancellationTokenSource = new CancellationTokenSource(); - var listener = new AsyncListener("listener", []); - bus.RegisterAsyncListener(listener); - - await bus.PublishAsync(new TestEvent("payload"), cancellationTokenSource.Token); - - Assert.Equal(cancellationTokenSource.Token, listener.CancellationToken); - } - [Fact] public async Task PublishAsync_WhenAsyncListenerThrows_PropagatesException() { @@ -101,81 +174,9 @@ public async Task PublishAsync_WhenAsyncListenerThrows_PropagatesException() bus.RegisterAsyncListener(new ThrowingAsyncListener(expected)); var actual = await Assert.ThrowsAsync( - () => bus.PublishAsync(new TestEvent("payload"), CancellationToken.None) - ); + () => bus.PublishAsync(new TestEvent("payload"), CancellationToken.None) + ); Assert.Same(expected, actual); } - - private sealed record TestEvent(string Payload) : IEvent; - - private sealed class SyncListener : ISyncEventListener - { - private readonly List _calls; - private readonly string _name; - - public TestEvent? LastEvent { get; private set; } - - public SyncListener(string name, List calls) - { - _name = name; - _calls = calls; - } - - public void Handle(TestEvent eventData) - { - LastEvent = eventData; - _calls.Add($"{_name}:{eventData.Payload}"); - } - } - - private sealed class ThrowingSyncListener : ISyncEventListener - { - private readonly InvalidOperationException _exception; - - public ThrowingSyncListener(InvalidOperationException exception) - { - _exception = exception; - } - - public void Handle(TestEvent eventData) - => throw _exception; - } - - private sealed class AsyncListener : IAsyncEventListener - { - private readonly List _calls; - private readonly string _name; - - public CancellationToken CancellationToken { get; private set; } - public TestEvent? LastEvent { get; private set; } - - public AsyncListener(string name, List calls) - { - _name = name; - _calls = calls; - } - - public async Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) - { - await Task.Yield(); - - CancellationToken = cancellationToken; - LastEvent = eventData; - _calls.Add($"{_name}:{eventData.Payload}"); - } - } - - private sealed class ThrowingAsyncListener : IAsyncEventListener - { - private readonly InvalidOperationException _exception; - - public ThrowingAsyncListener(InvalidOperationException exception) - { - _exception = exception; - } - - public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) - => throw _exception; - } } diff --git a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs index 33dc878a..5dc4e4ee 100644 --- a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Core.Data.Jobs; using SquidStd.Core.Interfaces.Jobs; using SquidStd.Services.Core.Services; @@ -9,7 +8,7 @@ public class JobSystemServiceTests [Fact] public async Task Schedule_Action_RunsAndCompletes() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; var called = false; await jobs.StartAsync(CancellationToken.None); @@ -23,7 +22,7 @@ public async Task Schedule_Action_RunsAndCompletes() [Fact] public async Task Schedule_Func_ReturnsValue() { - using var jobs = NewService(workerCount: 2); + using var jobs = NewService(2); IJobSystem system = jobs; await jobs.StartAsync(CancellationToken.None); @@ -35,7 +34,7 @@ public async Task Schedule_Func_ReturnsValue() [Fact] public async Task Schedule_ManyJobs_AllComplete() { - using var jobs = NewService(workerCount: 4); + using var jobs = NewService(4); IJobSystem system = jobs; var sum = 0; var sync = new Lock(); @@ -67,7 +66,7 @@ public async Task Schedule_ManyJobs_AllComplete() [Fact] public async Task Schedule_ThrowingAction_PropagatesExceptionToAwaiter() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; await jobs.StartAsync(CancellationToken.None); @@ -79,7 +78,7 @@ await Assert.ThrowsAsync( [Fact] public async Task Schedule_TokenAlreadyCancelled_ReturnsCanceledTask() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; using var cancellationTokenSource = new CancellationTokenSource(); await jobs.StartAsync(CancellationToken.None); @@ -94,7 +93,7 @@ public async Task Schedule_TokenAlreadyCancelled_ReturnsCanceledTask() [Fact] public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; using var gate = new ManualResetEventSlim(false); using var firstStarted = new ManualResetEventSlim(false); @@ -121,7 +120,7 @@ public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() [Fact] public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() { - var jobs = NewService(workerCount: 1); + var jobs = NewService(1); IJobSystem system = jobs; using var gate = new ManualResetEventSlim(false); using var firstStarted = new ManualResetEventSlim(false); @@ -151,24 +150,24 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() } [Fact] - public void WorkerCount_UsesExplicitValue() + public void WorkerCount_AutoDetectsAtLeastOne() { - using var jobs = NewService(workerCount: 3); + using var jobs = NewService(0); - Assert.Equal(3, jobs.WorkerCount); + Assert.True(jobs.WorkerCount >= 1); } [Fact] - public void WorkerCount_AutoDetectsAtLeastOne() + public void WorkerCount_UsesExplicitValue() { - using var jobs = NewService(workerCount: 0); + using var jobs = NewService(3); - Assert.True(jobs.WorkerCount >= 1); + Assert.Equal(3, jobs.WorkerCount); } private static JobSystemService NewService(int workerCount) => new( - new JobsConfig + new() { WorkerThreadCount = workerCount, ShutdownTimeoutSeconds = 1.0 diff --git a/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs b/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs index a0446b27..fb13ca65 100644 --- a/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs @@ -8,32 +8,6 @@ namespace SquidStd.Tests.Services.Core; public class MainThreadDispatcherServiceTests { - [Fact] - public void DrainPending_EmptyQueue_ReturnsZero() - { - IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); - - var executed = dispatcher.DrainPending(); - - Assert.Equal(0, executed); - Assert.Equal(0, dispatcher.PendingCount); - } - - [Fact] - public void DrainPending_NoBudget_DrainsAll() - { - IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); - var calls = new List(); - dispatcher.Post(() => calls.Add(1)); - dispatcher.Post(() => calls.Add(2)); - - var executed = dispatcher.DrainPending(); - - Assert.Equal(2, executed); - Assert.Equal([1, 2], calls); - Assert.Equal(0, dispatcher.PendingCount); - } - [Fact] public void DrainPending_BudgetExceededAfterFirstCallback_DefersRest() { @@ -91,6 +65,32 @@ public void DrainPending_CallbackThrows_ContinuesWithNext() Assert.Equal(1, calls); } + [Fact] + public void DrainPending_EmptyQueue_ReturnsZero() + { + IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); + + var executed = dispatcher.DrainPending(); + + Assert.Equal(0, executed); + Assert.Equal(0, dispatcher.PendingCount); + } + + [Fact] + public void DrainPending_NoBudget_DrainsAll() + { + IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); + var calls = new List(); + dispatcher.Post(() => calls.Add(1)); + dispatcher.Post(() => calls.Add(2)); + + var executed = dispatcher.DrainPending(); + + Assert.Equal(2, executed); + Assert.Equal([1, 2], calls); + Assert.Equal(0, dispatcher.PendingCount); + } + [Fact] public void Post_NullAction_ThrowsArgumentNullException() { @@ -99,6 +99,16 @@ public void Post_NullAction_ThrowsArgumentNullException() Assert.Throws(() => dispatcher.Post(null!)); } + [Fact] + public void RegisterCoreServices_RegistersMainThreadDispatcher() + { + using var container = new Container(); + + container.RegisterCoreServices(); + + Assert.IsType(container.Resolve()); + } + [Fact] public void SynchronizationContext_Post_EnqueuesIntoDispatcher() { @@ -125,14 +135,4 @@ public void SynchronizationContext_Send_InvokesDelegateSynchronously() Assert.Equal("sent", stateValue); Assert.Equal(0, dispatcher.PendingCount); } - - [Fact] - public void RegisterCoreServices_RegistersMainThreadDispatcher() - { - using var container = new Container(); - - container.RegisterCoreServices(); - - Assert.IsType(container.Resolve()); - } } diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index d2f5cc7d..cb5fec9d 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -12,26 +12,11 @@ namespace SquidStd.Tests.Services.Core; public class RegisterDefaultServicesExtensionsTests { - [Fact] - public void RegisterConfigManagerService_RegistersSingletonInstance() - { - using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); - - container.RegisterConfigManagerService("app", temp.Path); - - var first = container.Resolve(); - var second = container.Resolve(); - - Assert.Same(first, second); - Assert.Equal(Path.Combine(temp.Path, "app.yaml"), first.ConfigPath); - } - [Fact] public void RegisterConfigManagerService_AddsServiceRegistrationDataWithEarliestPriority() { using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterConfigManagerService("app", temp.Path); @@ -45,18 +30,18 @@ public void RegisterConfigManagerService_AddsServiceRegistrationDataWithEarliest } [Fact] - public void RegisterDefaultCoreConfigSections_RegistersJobsAndTimerWheelMetadata() + public void RegisterConfigManagerService_RegistersSingletonInstance() { - using var container = new DryIoc.Container(); + using var temp = new TempDirectory(); + using var container = new Container(); - container.RegisterDefaultCoreConfigSections(); + container.RegisterConfigManagerService("app", temp.Path); - var entries = container.Resolve>(); + var first = container.Resolve(); + var second = container.Resolve(); - Assert.Contains(entries, entry => entry.SectionName == "jobs" && entry.ConfigType == typeof(JobsConfig)); - Assert.Contains(entries, entry => entry.SectionName == "timerWheel" && entry.ConfigType == typeof(TimerWheelConfig)); - Assert.False(container.IsRegistered()); - Assert.False(container.IsRegistered()); + Assert.Same(first, second); + Assert.Equal(Path.Combine(temp.Path, "app.yaml"), first.ConfigPath); } [Fact] @@ -74,7 +59,7 @@ public async Task RegisterCoreServices_StartsConfigManagerBeforeResolvingConfigC WheelSize: 32 """ ); - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterCoreServices("app", temp.Path); var manager = container.Resolve(); @@ -85,4 +70,19 @@ public async Task RegisterCoreServices_StartsConfigManagerBeforeResolvingConfigC Assert.Equal(TimeSpan.FromMilliseconds(10), container.Resolve().TickDuration); Assert.Equal(32, container.Resolve().WheelSize); } + + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersJobsAndTimerWheelMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains(entries, entry => entry.SectionName == "jobs" && entry.ConfigType == typeof(JobsConfig)); + Assert.Contains(entries, entry => entry.SectionName == "timerWheel" && entry.ConfigType == typeof(TimerWheelConfig)); + Assert.False(container.IsRegistered()); + Assert.False(container.IsRegistered()); + } } diff --git a/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs b/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs index 94a87ec1..68f4aef6 100644 --- a/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Timing; using SquidStd.Services.Core.Services; @@ -7,50 +6,32 @@ namespace SquidStd.Tests.Services.Core; public class TimerWheelServiceTests { [Fact] - public void RegisterTimer_ReturnsNonEmptyDistinctIds() - { - ITimerService timer = NewService(); - - var first = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); - var second = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); - - Assert.False(string.IsNullOrEmpty(first)); - Assert.False(string.IsNullOrEmpty(second)); - Assert.NotEqual(first, second); - } - - [Fact] - public void OneShot_FiresExactlyOnceAtDueTime() + public void CallbackException_DoesNotStopOtherTimers() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); + ITimerService timer = NewService(8, 8); var calls = 0; - timer.RegisterTimer("once", TimeSpan.FromMilliseconds(8), () => calls++); + timer.RegisterTimer("bad", TimeSpan.FromMilliseconds(8), () => throw new InvalidOperationException("boom")); + timer.RegisterTimer("good", TimeSpan.FromMilliseconds(8), () => calls++); timer.UpdateTicksDelta(0); timer.UpdateTicksDelta(8); - timer.UpdateTicksDelta(16); - timer.UpdateTicksDelta(24); Assert.Equal(1, calls); } [Fact] - public void Repeating_FiresEveryInterval() + public void Ctor_InvalidConfig_Throws() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); - var calls = 0; - timer.RegisterTimer("repeat", TimeSpan.FromMilliseconds(16), () => calls++, repeat: true); - - timer.UpdateTicksDelta(0); - timer.UpdateTicksDelta(80); - - Assert.Equal(5, calls); + Assert.Throws(() => new TimerWheelService(new() { TickDuration = TimeSpan.Zero })); + Assert.Throws( + () => new TimerWheelService(new() { TickDuration = TimeSpan.FromMilliseconds(8), WheelSize = 0 }) + ); } [Fact] public void Delay_PostponesFirstExecutionThenUsesInterval() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 16); + ITimerService timer = NewService(); var timestamps = new List(); var current = 0L; timer.RegisterTimer( @@ -58,7 +39,7 @@ public void Delay_PostponesFirstExecutionThenUsesInterval() TimeSpan.FromMilliseconds(8), () => timestamps.Add(current), TimeSpan.FromMilliseconds(24), - repeat: true + true ); timer.UpdateTicksDelta(0); @@ -73,57 +54,44 @@ public void Delay_PostponesFirstExecutionThenUsesInterval() } [Fact] - public void UnregisterTimer_BeforeDueTime_PreventsCallback() + public void OneShot_FiresExactlyOnceAtDueTime() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); + ITimerService timer = NewService(8, 8); var calls = 0; - var timerId = timer.RegisterTimer("cancel", TimeSpan.FromMilliseconds(8), () => calls++); + timer.RegisterTimer("once", TimeSpan.FromMilliseconds(8), () => calls++); timer.UpdateTicksDelta(0); - Assert.True(timer.UnregisterTimer(timerId)); + timer.UpdateTicksDelta(8); timer.UpdateTicksDelta(16); + timer.UpdateTicksDelta(24); - Assert.Equal(0, calls); + Assert.Equal(1, calls); } [Fact] - public void UnregisterTimersByName_RemovesEveryMatchingTimer() + public void RegisterTimer_ReturnsNonEmptyDistinctIds() { ITimerService timer = NewService(); - timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); - timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); - timer.RegisterTimer("other", TimeSpan.FromMilliseconds(8), () => { }); - var removed = timer.UnregisterTimersByName("group"); + var first = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); + var second = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); - Assert.Equal(2, removed); - Assert.Equal(0, timer.UnregisterTimersByName("group")); - Assert.Equal(1, timer.UnregisterTimersByName("other")); + Assert.False(string.IsNullOrEmpty(first)); + Assert.False(string.IsNullOrEmpty(second)); + Assert.NotEqual(first, second); } [Fact] - public void CallbackException_DoesNotStopOtherTimers() + public void Repeating_FiresEveryInterval() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); + ITimerService timer = NewService(8, 8); var calls = 0; - timer.RegisterTimer("bad", TimeSpan.FromMilliseconds(8), () => throw new InvalidOperationException("boom")); - timer.RegisterTimer("good", TimeSpan.FromMilliseconds(8), () => calls++); - - timer.UpdateTicksDelta(0); - timer.UpdateTicksDelta(8); - - Assert.Equal(1, calls); - } + timer.RegisterTimer("repeat", TimeSpan.FromMilliseconds(16), () => calls++, repeat: true); - [Fact] - public void UpdateTicksDelta_AdvancesByWholeTicks() - { - ITimerService timer = NewService(tickDurationMs: 8); timer.UpdateTicksDelta(0); + timer.UpdateTicksDelta(80); - var processed = timer.UpdateTicksDelta(24); - - Assert.Equal(3, processed); + Assert.Equal(5, calls); } [Fact] @@ -141,19 +109,48 @@ public async Task StopAsync_ClearsState() } [Fact] - public void Ctor_InvalidConfig_Throws() + public void UnregisterTimer_BeforeDueTime_PreventsCallback() { - Assert.Throws( - () => new TimerWheelService(new TimerWheelConfig { TickDuration = TimeSpan.Zero }) - ); - Assert.Throws( - () => new TimerWheelService(new TimerWheelConfig { TickDuration = TimeSpan.FromMilliseconds(8), WheelSize = 0 }) - ); + ITimerService timer = NewService(8, 8); + var calls = 0; + var timerId = timer.RegisterTimer("cancel", TimeSpan.FromMilliseconds(8), () => calls++); + + timer.UpdateTicksDelta(0); + Assert.True(timer.UnregisterTimer(timerId)); + timer.UpdateTicksDelta(16); + + Assert.Equal(0, calls); + } + + [Fact] + public void UnregisterTimersByName_RemovesEveryMatchingTimer() + { + ITimerService timer = NewService(); + timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); + timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); + timer.RegisterTimer("other", TimeSpan.FromMilliseconds(8), () => { }); + + var removed = timer.UnregisterTimersByName("group"); + + Assert.Equal(2, removed); + Assert.Equal(0, timer.UnregisterTimersByName("group")); + Assert.Equal(1, timer.UnregisterTimersByName("other")); + } + + [Fact] + public void UpdateTicksDelta_AdvancesByWholeTicks() + { + ITimerService timer = NewService(); + timer.UpdateTicksDelta(0); + + var processed = timer.UpdateTicksDelta(24); + + Assert.Equal(3, processed); } private static TimerWheelService NewService(int tickDurationMs = 8, int wheelSize = 16) => new( - new TimerWheelConfig + new() { TickDuration = TimeSpan.FromMilliseconds(tickDurationMs), WheelSize = wheelSize diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 6fd31a56..c11bb1d6 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -1,33 +1,33 @@  - - net10.0 - enable - enable - false - + + net10.0 + enable + enable + false + - - - - - - + + + + + + - - - + + + - - - + + + - - - - - - - + + + + + + + diff --git a/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs b/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs index 8e5a3906..43937999 100644 --- a/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs +++ b/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs @@ -24,13 +24,6 @@ public FakeNetworkConnection(long sessionId = 1, EndPoint? remoteEndPoint = null RemoteEndPoint = remoteEndPoint ?? new IPEndPoint(IPAddress.Loopback, 1234); } - public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) - { - _sent.Add(payload.ToArray()); - - return SendCallback?.Invoke(payload) ?? Task.CompletedTask; - } - public Task CloseAsync(CancellationToken cancellationToken = default) { CloseCount++; @@ -38,4 +31,11 @@ public Task CloseAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } + + public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + { + _sent.Add(payload.ToArray()); + + return SendCallback?.Invoke(payload) ?? Task.CompletedTask; + } } diff --git a/tests/SquidStd.Tests/Support/FakePlugin.cs b/tests/SquidStd.Tests/Support/FakePlugin.cs index 1c272909..93569292 100644 --- a/tests/SquidStd.Tests/Support/FakePlugin.cs +++ b/tests/SquidStd.Tests/Support/FakePlugin.cs @@ -16,7 +16,7 @@ public class FakePlugin : ISquidStdPlugin { Id = "squidstd.fake", Name = "Fake Plugin", - Version = new Version(1, 2, 3), + Version = new(1, 2, 3), Author = "tests" }; diff --git a/tests/SquidStd.Tests/Support/TempDirectory.cs b/tests/SquidStd.Tests/Support/TempDirectory.cs index a772aaec..931f7259 100644 --- a/tests/SquidStd.Tests/Support/TempDirectory.cs +++ b/tests/SquidStd.Tests/Support/TempDirectory.cs @@ -32,7 +32,7 @@ public void Dispose() { if (Directory.Exists(Path)) { - Directory.Delete(Path, recursive: true); + Directory.Delete(Path, true); } } catch diff --git a/tests/SquidStd.Tests/Support/TestJsonContext.cs b/tests/SquidStd.Tests/Support/TestJsonContext.cs index 4d59a1e7..f6c211e3 100644 --- a/tests/SquidStd.Tests/Support/TestJsonContext.cs +++ b/tests/SquidStd.Tests/Support/TestJsonContext.cs @@ -5,8 +5,5 @@ namespace SquidStd.Tests.Support; /// /// Source-generated JSON serializer context exposing the test DTO types. /// -[JsonSerializable(typeof(SampleDto))] -[JsonSerializable(typeof(OtherDto))] -public partial class TestJsonContext : JsonSerializerContext -{ -} +[JsonSerializable(typeof(SampleDto)), JsonSerializable(typeof(OtherDto))] +public partial class TestJsonContext : JsonSerializerContext { } diff --git a/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs b/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs index 7fdf1a8b..2a7c61a9 100644 --- a/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs @@ -6,37 +6,37 @@ namespace SquidStd.Tests.Utils; public class DirectoriesUtilsTests { [Fact] - public void GetFiles_NonExistentDirectory_ReturnsEmpty() - => Assert.Empty(DirectoriesUtils.GetFiles(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")))); - - [Fact] - public void GetFiles_NoExtensionFilter_ReturnsAllFiles() + public void GetFiles_ExtensionFilter_ReturnsMatchingFilesOnly() { using var temp = new TempDirectory(); File.WriteAllText(temp.Combine("a.txt"), "a"); File.WriteAllText(temp.Combine("b.json"), "b"); + File.WriteAllText(temp.Combine("c.json"), "c"); - var files = DirectoriesUtils.GetFiles(temp.Path); + var files = DirectoriesUtils.GetFiles(temp.Path, "*.json"); Assert.Equal(2, files.Length); + Assert.All(files, file => Assert.EndsWith(".json", file)); } [Fact] - public void GetFiles_ExtensionFilter_ReturnsMatchingFilesOnly() + public void GetFiles_NoExtensionFilter_ReturnsAllFiles() { using var temp = new TempDirectory(); File.WriteAllText(temp.Combine("a.txt"), "a"); File.WriteAllText(temp.Combine("b.json"), "b"); - File.WriteAllText(temp.Combine("c.json"), "c"); - var files = DirectoriesUtils.GetFiles(temp.Path, "*.json"); + var files = DirectoriesUtils.GetFiles(temp.Path); Assert.Equal(2, files.Length); - Assert.All(files, file => Assert.EndsWith(".json", file)); } [Fact] - public void GetFiles_Recursive_IncludesNestedFiles() + public void GetFiles_NonExistentDirectory_ReturnsEmpty() + => Assert.Empty(DirectoriesUtils.GetFiles(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")))); + + [Fact] + public void GetFiles_NonRecursive_ExcludesNestedFiles() { using var temp = new TempDirectory(); var nested = temp.Combine("nested"); @@ -44,13 +44,14 @@ public void GetFiles_Recursive_IncludesNestedFiles() File.WriteAllText(temp.Combine("root.txt"), "root"); File.WriteAllText(Path.Combine(nested, "child.txt"), "child"); - var files = DirectoriesUtils.GetFiles(temp.Path, recursive: true, "*.txt"); + var files = DirectoriesUtils.GetFiles(temp.Path, false, "*.txt"); - Assert.Equal(2, files.Length); + Assert.Single(files); + Assert.EndsWith("root.txt", files[0]); } [Fact] - public void GetFiles_NonRecursive_ExcludesNestedFiles() + public void GetFiles_Recursive_IncludesNestedFiles() { using var temp = new TempDirectory(); var nested = temp.Combine("nested"); @@ -58,9 +59,8 @@ public void GetFiles_NonRecursive_ExcludesNestedFiles() File.WriteAllText(temp.Combine("root.txt"), "root"); File.WriteAllText(Path.Combine(nested, "child.txt"), "child"); - var files = DirectoriesUtils.GetFiles(temp.Path, recursive: false, "*.txt"); + var files = DirectoriesUtils.GetFiles(temp.Path, true, "*.txt"); - Assert.Single(files); - Assert.EndsWith("root.txt", files[0]); + Assert.Equal(2, files.Length); } } diff --git a/tests/SquidStd.Tests/Utils/HashUtilsTests.cs b/tests/SquidStd.Tests/Utils/HashUtilsTests.cs index 66e11b7c..be8ca6dc 100644 --- a/tests/SquidStd.Tests/Utils/HashUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/HashUtilsTests.cs @@ -4,6 +4,19 @@ namespace SquidStd.Tests.Utils; public class HashUtilsTests { + [Theory, InlineData(""), InlineData(" "), InlineData(null)] + public void HashPassword_NullOrWhitespace_Throws(string? password) + => Assert.Throws(() => HashUtils.HashPassword(password!)); + + [Fact] + public void HashPassword_SamePasswordTwice_ProducesDifferentHashes() + { + var first = HashUtils.HashPassword("s3cret"); + var second = HashUtils.HashPassword("s3cret"); + + Assert.NotEqual(first, second); + } + [Fact] public void HashPassword_ValidPassword_ReturnsSerializedPayload() { @@ -18,22 +31,6 @@ public void HashPassword_ValidPassword_ReturnsSerializedPayload() Assert.NotEmpty(parts[3]); } - [Fact] - public void HashPassword_SamePasswordTwice_ProducesDifferentHashes() - { - var first = HashUtils.HashPassword("s3cret"); - var second = HashUtils.HashPassword("s3cret"); - - Assert.NotEqual(first, second); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void HashPassword_NullOrWhitespace_Throws(string? password) - => Assert.Throws(() => HashUtils.HashPassword(password!)); - [Fact] public void VerifyPassword_CorrectPassword_ReturnsTrue() { @@ -42,6 +39,16 @@ public void VerifyPassword_CorrectPassword_ReturnsTrue() Assert.True(HashUtils.VerifyPassword("s3cret", hash)); } + [Theory, InlineData("not-a-hash"), InlineData("md5$100000$c2FsdA==$aGFzaA=="), + InlineData("pbkdf2-sha256$abc$c2FsdA==$aGFzaA=="), InlineData("pbkdf2-sha256$0$c2FsdA==$aGFzaA=="), + InlineData("pbkdf2-sha256$100000$not-base64$aGFzaA==")] + public void VerifyPassword_MalformedStoredHash_ReturnsFalse(string storedHash) + => Assert.False(HashUtils.VerifyPassword("s3cret", storedHash)); + + [Theory, InlineData(""), InlineData(" "), InlineData(null)] + public void VerifyPassword_NullOrWhitespacePassword_ReturnsFalse(string? password) + => Assert.False(HashUtils.VerifyPassword(password!, "pbkdf2-sha256$100000$c2FsdA==$aGFzaA==")); + [Fact] public void VerifyPassword_WrongPassword_ReturnsFalse() { @@ -49,20 +56,4 @@ public void VerifyPassword_WrongPassword_ReturnsFalse() Assert.False(HashUtils.VerifyPassword("wrong", hash)); } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void VerifyPassword_NullOrWhitespacePassword_ReturnsFalse(string? password) - => Assert.False(HashUtils.VerifyPassword(password!, "pbkdf2-sha256$100000$c2FsdA==$aGFzaA==")); - - [Theory] - [InlineData("not-a-hash")] - [InlineData("md5$100000$c2FsdA==$aGFzaA==")] - [InlineData("pbkdf2-sha256$abc$c2FsdA==$aGFzaA==")] - [InlineData("pbkdf2-sha256$0$c2FsdA==$aGFzaA==")] - [InlineData("pbkdf2-sha256$100000$not-base64$aGFzaA==")] - public void VerifyPassword_MalformedStoredHash_ReturnsFalse(string storedHash) - => Assert.False(HashUtils.VerifyPassword("s3cret", storedHash)); } diff --git a/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs b/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs index 8051315e..6d634a6c 100644 --- a/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs @@ -6,72 +6,63 @@ namespace SquidStd.Tests.Utils; public class NetworkUtilsTests { [Fact] - public void ParseIpAddress_Wildcard_ReturnsAny() - => Assert.Equal(IPAddress.Any, NetworkUtils.ParseIpAddress("*")); + public void GetListeningAddresses_NullEndpoint_Throws() + => Assert.Throws(() => NetworkUtils.GetListeningAddresses(null!).ToList()); [Fact] - public void ParseIpAddress_ValidAddress_ReturnsParsedAddress() - => Assert.Equal(IPAddress.Parse("127.0.0.1"), NetworkUtils.ParseIpAddress("127.0.0.1")); + public void GetListeningAddresses_ReturnsEndpointsMatchingFamilyAndPort() + { + var template = new IPEndPoint(IPAddress.Loopback, 6667); - [Theory] - [InlineData("")] - [InlineData(" ")] + var results = NetworkUtils.GetListeningAddresses(template).ToList(); + + Assert.All( + results, + endpoint => + { + Assert.Equal(6667, endpoint.Port); + Assert.Equal(template.AddressFamily, endpoint.AddressFamily); + } + ); + } + + [Theory, InlineData(""), InlineData(" ")] public void ParseIpAddress_NullOrWhitespace_Throws(string ipAddress) => Assert.Throws(() => NetworkUtils.ParseIpAddress(ipAddress)); [Fact] - public void ParsePorts_SinglePort_ReturnsSingleEntry() - => Assert.Equal([8000], NetworkUtils.ParsePorts("8000")); - - [Fact] - public void ParsePorts_Range_ReturnsExpandedRange() - => Assert.Equal([6666, 6667, 6668], NetworkUtils.ParsePorts("6666-6668")); + public void ParseIpAddress_ValidAddress_ReturnsParsedAddress() + => Assert.Equal(IPAddress.Parse("127.0.0.1"), NetworkUtils.ParseIpAddress("127.0.0.1")); [Fact] - public void ParsePorts_MixedRangeAndList_ReturnsAllPorts() - => Assert.Equal([6666, 6667, 6668, 6669, 8000], NetworkUtils.ParsePorts("6666-6668,6669,8000")); + public void ParseIpAddress_Wildcard_ReturnsAny() + => Assert.Equal(IPAddress.Any, NetworkUtils.ParseIpAddress("*")); - [Fact] - public void ParsePorts_TrimsWhitespaceEntries() - => Assert.Equal([80, 443], NetworkUtils.ParsePorts(" 80 , 443 ")); + [Theory, InlineData("99999"), InlineData("-1"), InlineData("abc")] + public void ParsePorts_InvalidPort_ThrowsFormatException(string ports) + => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); - [Theory] - [InlineData("8000-7000")] - [InlineData("1-2-3")] + [Theory, InlineData("8000-7000"), InlineData("1-2-3")] public void ParsePorts_InvalidRange_ThrowsFormatException(string ports) => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); - [Theory] - [InlineData("99999")] - [InlineData("-1")] - [InlineData("abc")] - public void ParsePorts_InvalidPort_ThrowsFormatException(string ports) - => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + [Fact] + public void ParsePorts_MixedRangeAndList_ReturnsAllPorts() + => Assert.Equal([6666, 6667, 6668, 6669, 8000], NetworkUtils.ParsePorts("6666-6668,6669,8000")); - [Theory] - [InlineData("")] - [InlineData(" ")] + [Theory, InlineData(""), InlineData(" ")] public void ParsePorts_NullOrWhitespace_ThrowsArgumentException(string ports) => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); [Fact] - public void GetListeningAddresses_NullEndpoint_Throws() - => Assert.Throws(() => NetworkUtils.GetListeningAddresses(null!).ToList()); + public void ParsePorts_Range_ReturnsExpandedRange() + => Assert.Equal([6666, 6667, 6668], NetworkUtils.ParsePorts("6666-6668")); [Fact] - public void GetListeningAddresses_ReturnsEndpointsMatchingFamilyAndPort() - { - var template = new IPEndPoint(IPAddress.Loopback, 6667); - - var results = NetworkUtils.GetListeningAddresses(template).ToList(); + public void ParsePorts_SinglePort_ReturnsSingleEntry() + => Assert.Equal([8000], NetworkUtils.ParsePorts("8000")); - Assert.All( - results, - endpoint => - { - Assert.Equal(6667, endpoint.Port); - Assert.Equal(template.AddressFamily, endpoint.AddressFamily); - } - ); - } + [Fact] + public void ParsePorts_TrimsWhitespaceEntries() + => Assert.Equal([80, 443], NetworkUtils.ParsePorts(" 80 , 443 ")); } diff --git a/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs b/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs index 824a856f..01e1512e 100644 --- a/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs @@ -8,10 +8,9 @@ public class PlatformUtilsTests [Fact] public void GetCurrentPlatform_MatchesOperatingSystemDetection() { - var expected = OperatingSystem.IsWindows() ? PlatformType.Windows - : OperatingSystem.IsMacOS() ? PlatformType.MacOS - : OperatingSystem.IsLinux() ? PlatformType.Linux - : PlatformType.Unknown; + var expected = OperatingSystem.IsWindows() ? PlatformType.Windows : + OperatingSystem.IsMacOS() ? PlatformType.MacOS : + OperatingSystem.IsLinux() ? PlatformType.Linux : PlatformType.Unknown; Assert.Equal(expected, PlatformUtils.GetCurrentPlatform()); } diff --git a/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs b/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs index 4b1ff0b2..5e685662 100644 --- a/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs @@ -9,41 +9,33 @@ public class ResourceUtilsTests private const string SampleResourceSuffix = "Support.Resources.sample.txt"; [Fact] - public void ConvertResourceNameToPath_ValidName_ReturnsFilePath() + public void ConvertResourceNameToPath_NestedName_ConvertsDotsToSeparators() { - var expected = Path.Combine("cfg") + ".json"; + var expected = string.Join(Path.DirectorySeparatorChar, "Folder", "Sub", "file") + ".txt"; - Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.cfg.json", "Asm")); + Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.Folder.Sub.file.txt", "Asm")); } [Fact] - public void ConvertResourceNameToPath_NestedName_ConvertsDotsToSeparators() + public void ConvertResourceNameToPath_NoExtension_Throws() + => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Asm.file", "Asm")); + + [Fact] + public void ConvertResourceNameToPath_ValidName_ReturnsFilePath() { - var expected = string.Join(Path.DirectorySeparatorChar, "Folder", "Sub", "file") + ".txt"; + var expected = Path.Combine("cfg") + ".json"; - Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.Folder.Sub.file.txt", "Asm")); + Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.cfg.json", "Asm")); } [Fact] public void ConvertResourceNameToPath_WrongNamespace_Throws() => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Other.cfg.json", "Asm")); - [Fact] - public void ConvertResourceNameToPath_NoExtension_Throws() - => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Asm.file", "Asm")); - [Fact] public void EmbeddedNameToPath_StripsPrefixAndConvertsDots() => Assert.Equal("Folder/file/txt", ResourceUtils.EmbeddedNameToPath("Asm.Folder.file.txt", "Asm")); - [Fact] - public void GetDirectoryPathFromResourceName_ReturnsDirectoryPart() - { - var expected = string.Join(Path.DirectorySeparatorChar, "Assets", "Fonts"); - - Assert.Equal(expected, ResourceUtils.GetDirectoryPathFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); - } - [Fact] public void GetDirectoryPathFromResourceName_RemovesBaseNamespace() { @@ -53,15 +45,12 @@ public void GetDirectoryPathFromResourceName_RemovesBaseNamespace() } [Fact] - public void GetFileNameFromResourceName_ReturnsFileNameWithExtension() - => Assert.Equal("DefaultUiFont.ttf", ResourceUtils.GetFileNameFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + public void GetDirectoryPathFromResourceName_ReturnsDirectoryPart() + { + var expected = string.Join(Path.DirectorySeparatorChar, "Assets", "Fonts"); - [Theory] - [InlineData("a/b/c.txt", "c.txt")] - [InlineData("a\\b\\c.txt", "c.txt")] - [InlineData("c.txt", "c.txt")] - public void GetFileNameFromResourcePath_ReturnsFinalSegment(string input, string expected) - => Assert.Equal(expected, ResourceUtils.GetFileNameFromResourcePath(input)); + Assert.Equal(expected, ResourceUtils.GetDirectoryPathFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + } [Fact] public void GetEmbeddedResourceNames_NoFilter_IncludesSampleResource() @@ -71,6 +60,12 @@ public void GetEmbeddedResourceNames_NoFilter_IncludesSampleResource() Assert.Contains(names, name => name.EndsWith(SampleResourceSuffix, StringComparison.Ordinal)); } + [Fact] + public void GetEmbeddedResourceStream_MissingResource_Throws() + => Assert.Throws( + () => ResourceUtils.GetEmbeddedResourceStream(TestAssembly, "does-not-exist.bin") + ); + [Fact] public void GetEmbeddedResourceString_ExistingResource_ReturnsContent() { @@ -79,6 +74,14 @@ public void GetEmbeddedResourceString_ExistingResource_ReturnsContent() Assert.Contains("embedded-resource-content", content); } + [Fact] + public void GetFileNameFromResourceName_ReturnsFileNameWithExtension() + => Assert.Equal("DefaultUiFont.ttf", ResourceUtils.GetFileNameFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + + [Theory, InlineData("a/b/c.txt", "c.txt"), InlineData("a\\b\\c.txt", "c.txt"), InlineData("c.txt", "c.txt")] + public void GetFileNameFromResourcePath_ReturnsFinalSegment(string input, string expected) + => Assert.Equal(expected, ResourceUtils.GetFileNameFromResourcePath(input)); + [Fact] public void ReadEmbeddedResource_ExistingResource_ReturnsContent() { @@ -88,11 +91,9 @@ public void ReadEmbeddedResource_ExistingResource_ReturnsContent() Assert.Contains("embedded-resource-content", content); } - [Fact] - public void GetEmbeddedResourceStream_MissingResource_Throws() - => Assert.Throws(() => ResourceUtils.GetEmbeddedResourceStream(TestAssembly, "does-not-exist.bin")); - [Fact] public void ReadEmbeddedResource_MissingResource_Throws() - => Assert.Throws(() => ResourceUtils.ReadEmbeddedResource("does-not-exist.bin", TestAssembly)); + => Assert.Throws( + () => ResourceUtils.ReadEmbeddedResource("does-not-exist.bin", TestAssembly) + ); } diff --git a/tests/SquidStd.Tests/Utils/StringUtilsTests.cs b/tests/SquidStd.Tests/Utils/StringUtilsTests.cs index d3816d8c..c2850852 100644 --- a/tests/SquidStd.Tests/Utils/StringUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/StringUtilsTests.cs @@ -4,90 +4,65 @@ namespace SquidStd.Tests.Utils; public class StringUtilsTests { - [Theory] - [InlineData("HelloWorld", "helloWorld")] - [InlineData("API_RESPONSE", "apiResponse")] - [InlineData("user-id", "userId")] - [InlineData("hello world", "helloWorld")] + [Theory, InlineData(""), InlineData(null)] + public void ToCamelCase_NullOrEmpty_ReturnsEmpty(string? input) + => Assert.Equal("", StringUtils.ToCamelCase(input!)); + + [Fact] + public void ToCamelCase_SingleCharacter_ReturnsLowerCase() + => Assert.Equal("a", StringUtils.ToCamelCase("A")); + + [Theory, InlineData("HelloWorld", "helloWorld"), InlineData("API_RESPONSE", "apiResponse"), + InlineData("user-id", "userId"), InlineData("hello world", "helloWorld")] public void ToCamelCase_VariousInputs_ReturnsCamelCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToCamelCase(input)); - [Theory] - [InlineData("HelloWorld", "hello.world")] - [InlineData("API_RESPONSE", "api.response")] + [Theory, InlineData("HelloWorld", "hello.world"), InlineData("API_RESPONSE", "api.response")] public void ToDotCase_VariousInputs_ReturnsDotCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToDotCase(input)); - [Theory] - [InlineData("HelloWorld", "hello-world")] - [InlineData("API_RESPONSE", "api-response")] - [InlineData("userId", "user-id")] + [Theory, InlineData("HelloWorld", "hello-world"), InlineData("API_RESPONSE", "api-response"), + InlineData("userId", "user-id")] public void ToKebabCase_VariousInputs_ReturnsKebabCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToKebabCase(input)); - [Theory] - [InlineData("hello_world", "HelloWorld")] - [InlineData("api-response", "ApiResponse")] - [InlineData("userId", "UserId")] + [Fact] + public void ToPascalCase_SingleCharacter_ReturnsUpperCase() + => Assert.Equal("A", StringUtils.ToPascalCase("a")); + + [Theory, InlineData("hello_world", "HelloWorld"), InlineData("api-response", "ApiResponse"), + InlineData("userId", "UserId")] public void ToPascalCase_VariousInputs_ReturnsPascalCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToPascalCase(input)); - [Theory] - [InlineData("HelloWorld", "hello/world")] - [InlineData("API_RESPONSE", "api/response")] + [Theory, InlineData("HelloWorld", "hello/world"), InlineData("API_RESPONSE", "api/response")] public void ToPathCase_VariousInputs_ReturnsPathCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToPathCase(input)); - [Theory] - [InlineData("hello world", "Hello world")] - [InlineData("API_RESPONSE", "Api response")] + [Theory, InlineData("hello world", "Hello world"), InlineData("API_RESPONSE", "Api response")] public void ToSentenceCase_VariousInputs_ReturnsSentenceCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToSentenceCase(input)); - [Theory] - [InlineData("HelloWorld", "hello_world")] - [InlineData("APIResponse", "api_response")] - [InlineData("userId", "user_id")] + [Theory, InlineData(""), InlineData(null)] + public void ToSnakeCase_NullOrEmpty_ReturnsEmpty(string? input) + => Assert.Equal("", StringUtils.ToSnakeCase(input!)); + + [Theory, InlineData("HelloWorld", "hello_world"), InlineData("APIResponse", "api_response"), + InlineData("userId", "user_id")] public void ToSnakeCase_VariousInputs_ReturnsSnakeCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToSnakeCase(input)); - [Theory] - [InlineData("hello_world", "Hello World")] - [InlineData("API_RESPONSE", "Api Response")] - [InlineData("user-id", "User Id")] + [Theory, InlineData("hello_world", "Hello World"), InlineData("API_RESPONSE", "Api Response"), + InlineData("user-id", "User Id")] public void ToTitleCase_VariousInputs_ReturnsTitleCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToTitleCase(input)); - [Theory] - [InlineData("hello_world", "Hello-World")] - [InlineData("apiResponse", "Api-Response")] + [Theory, InlineData("hello_world", "Hello-World"), InlineData("apiResponse", "Api-Response")] public void ToTrainCase_VariousInputs_ReturnsTrainCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToTrainCase(input)); - [Theory] - [InlineData("HelloWorld", "HELLO_WORLD")] - [InlineData("apiResponse", "API_RESPONSE")] - [InlineData("user-id", "USER_ID")] + [Theory, InlineData("HelloWorld", "HELLO_WORLD"), InlineData("apiResponse", "API_RESPONSE"), + InlineData("user-id", "USER_ID")] public void ToUpperSnakeCase_VariousInputs_ReturnsScreamingSnakeCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToUpperSnakeCase(input)); - - [Theory] - [InlineData("")] - [InlineData(null)] - public void ToCamelCase_NullOrEmpty_ReturnsEmpty(string? input) - => Assert.Equal("", StringUtils.ToCamelCase(input!)); - - [Theory] - [InlineData("")] - [InlineData(null)] - public void ToSnakeCase_NullOrEmpty_ReturnsEmpty(string? input) - => Assert.Equal("", StringUtils.ToSnakeCase(input!)); - - [Fact] - public void ToPascalCase_SingleCharacter_ReturnsUpperCase() - => Assert.Equal("A", StringUtils.ToPascalCase("a")); - - [Fact] - public void ToCamelCase_SingleCharacter_ReturnsLowerCase() - => Assert.Equal("a", StringUtils.ToCamelCase("A")); } diff --git a/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs b/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs index 03f5bde4..218cd8c7 100644 --- a/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs +++ b/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs @@ -5,48 +5,10 @@ namespace SquidStd.Tests.Yaml; public class YamlUtilsTests { - [Fact] - public void SerializeDeserialize_RoundTrip_PreservesValues() - { - var original = new SampleDto { Name = "squid", Count = 42 }; - - var yaml = YamlUtils.Serialize(original); - var restored = YamlUtils.Deserialize(yaml); - - Assert.Equal(original.Name, restored.Name); - Assert.Equal(original.Count, restored.Count); - } - - [Fact] - public void Serialize_NullObject_Throws() - => Assert.Throws(() => YamlUtils.Serialize(null!)); - - [Theory] - [InlineData("")] - [InlineData(" ")] + [Theory, InlineData(""), InlineData(" ")] public void Deserialize_NullOrWhitespace_Throws(string yaml) => Assert.Throws(() => YamlUtils.Deserialize(yaml)); - [Fact] - public void SerializeToFile_DeserializeFromFile_RoundTrips() - { - using var temp = new TempDirectory(); - var path = temp.Combine("nested/sample.yaml"); - - YamlUtils.SerializeToFile(new SampleDto { Name = "file", Count = 9 }, path); - var restored = YamlUtils.DeserializeFromFile(path); - - Assert.True(File.Exists(path)); - Assert.Equal("file", restored.Name); - Assert.Equal(9, restored.Count); - } - - [Fact] - public void DeserializeFromFile_MissingFile_Throws() - => Assert.Throws( - () => YamlUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".yaml")) - ); - [Fact] public void Deserialize_RuntimeType_ReturnsTypedObject() { @@ -63,6 +25,12 @@ public void Deserialize_RuntimeType_ReturnsTypedObject() Assert.Equal(8, dto.Count); } + [Fact] + public void DeserializeFromFile_MissingFile_Throws() + => Assert.Throws( + () => YamlUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".yaml")) + ); + [Fact] public void DeserializeSection_ExistingSection_ReturnsTypedObject() { @@ -93,6 +61,22 @@ public void DeserializeSection_MissingSection_ReturnsNull() Assert.Null(result); } + [Fact] + public void Serialize_NullObject_Throws() + => Assert.Throws(() => YamlUtils.Serialize(null!)); + + [Fact] + public void SerializeDeserialize_RoundTrip_PreservesValues() + { + var original = new SampleDto { Name = "squid", Count = 42 }; + + var yaml = YamlUtils.Serialize(original); + var restored = YamlUtils.Deserialize(yaml); + + Assert.Equal(original.Name, restored.Name); + Assert.Equal(original.Count, restored.Count); + } + [Fact] public void SerializeSections_WritesRootSectionNames() { @@ -107,4 +91,18 @@ public void SerializeSections_WritesRootSectionNames() Assert.Contains("Name: root", yaml); Assert.Contains("Count: 12", yaml); } + + [Fact] + public void SerializeToFile_DeserializeFromFile_RoundTrips() + { + using var temp = new TempDirectory(); + var path = temp.Combine("nested/sample.yaml"); + + YamlUtils.SerializeToFile(new SampleDto { Name = "file", Count = 9 }, path); + var restored = YamlUtils.DeserializeFromFile(path); + + Assert.True(File.Exists(path)); + Assert.Equal("file", restored.Name); + Assert.Equal(9, restored.Count); + } } From 4f65c84533af6653ea627d792904db4bc76b24cd Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:08:16 +0200 Subject: [PATCH 02/98] feat(udp): make UDP server observable with OnDatagramReceived and targeted SendToAsync --- .../SquidStdUdpDatagramReceivedEventArgs.cs | 21 +++++++++ .../Server/SquidStdUdpServer.cs | 43 +++++++++++++++++++ .../Network/SquidStdUdpServerTests.cs | 42 ++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs diff --git a/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs new file mode 100644 index 00000000..2cd200f2 --- /dev/null +++ b/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs @@ -0,0 +1,21 @@ +using System.Net; + +namespace SquidStd.Network.Data.Events; + +/// +/// Event payload for a datagram received by the UDP server, carrying the sender endpoint. +/// +public sealed class SquidStdUdpDatagramReceivedEventArgs : EventArgs +{ + /// Endpoint that sent the datagram. + public IPEndPoint RemoteEndPoint { get; } + + /// Received datagram payload. + public ReadOnlyMemory Data { get; } + + public SquidStdUdpDatagramReceivedEventArgs(IPEndPoint remoteEndPoint, ReadOnlyMemory data) + { + RemoteEndPoint = remoteEndPoint; + Data = data; + } +} diff --git a/src/SquidStd.Network/Server/SquidStdUdpServer.cs b/src/SquidStd.Network/Server/SquidStdUdpServer.cs index 1bcc41ca..598c53a4 100644 --- a/src/SquidStd.Network/Server/SquidStdUdpServer.cs +++ b/src/SquidStd.Network/Server/SquidStdUdpServer.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using Serilog; @@ -22,6 +23,7 @@ public sealed class SquidStdUdpServer : INetworkServer, IAsyncDisposable, IDispo private readonly ILogger _logger = Log.ForContext(); private readonly List _receiveLoops = []; private readonly Lock _sync = new(); + private readonly ConcurrentDictionary _endpointListeners = new(); private CancellationTokenSource? _cancellationTokenSource; private int _started; @@ -78,6 +80,12 @@ public int ListenerCount } } + /// + /// Raised for every datagram received, carrying the sender endpoint. Always raised, regardless of + /// . + /// + public event EventHandler? OnDatagramReceived; + /// /// Raised when receive loops throw an unexpected exception. /// @@ -236,6 +244,9 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel try { var result = await listener.ReceiveAsync(cancellationToken); + _endpointListeners[result.RemoteEndPoint] = listener; + OnDatagramReceived?.Invoke(this, new(result.RemoteEndPoint, result.Buffer)); + var response = OnDatagram is null ? result.Buffer : OnDatagram(result.Buffer, result.RemoteEndPoint); @@ -265,6 +276,38 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel } } + /// + /// Sends a datagram to a specific endpoint, using the listener that last received from it + /// (falling back to the first listener). No-op when no listener is available. + /// + public async Task SendToAsync(IPEndPoint endPoint, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endPoint); + + if (!_endpointListeners.TryGetValue(endPoint, out var listener)) + { + lock (_sync) + { + listener = _listeners.FirstOrDefault(); + } + } + + if (listener is null) + { + return; + } + + try + { + await listener.SendAsync(payload, endPoint, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "UDP SendToAsync failed for {EndPoint}", endPoint); + OnException?.Invoke(this, new(ex)); + } + } + private IEnumerable ResolveBindEndPoints() { if (!_bindAllInterfaces) diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs index 059b94fa..90737b6a 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs @@ -80,4 +80,46 @@ public async Task StartStop_TogglesIsRunning() await server.StartAsync(CancellationToken.None); Assert.True(server.IsRunning); } + + [Fact] + public async Task OnDatagramReceived_RaisedForIncomingDatagram() + { + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.OnDatagramReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await server.StartAsync(CancellationToken.None); + var serverPort = server.Port; + + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await client.StartAsync(CancellationToken.None); + await client.SendToAsync(new byte[] { 1, 2, 3 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + + Assert.Equal([1, 2, 3], await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task SendToAsync_DeliversToEndpointThatWasSeen() + { + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false) + { + OnDatagram = static (_, _) => ReadOnlyMemory.Empty // suppress default echo for this test + }; + var senderEndpoint = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.OnDatagramReceived += (_, e) => senderEndpoint.TrySetResult(e.RemoteEndPoint); + await server.StartAsync(CancellationToken.None); + var serverPort = server.Port; + + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var clientReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.OnDataReceived += (_, e) => clientReceived.TrySetResult(e.Data.ToArray()); + await client.StartAsync(CancellationToken.None); + + // Make the server "see" the client endpoint first. + await client.SendToAsync(new byte[] { 0 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + var clientEndpoint = await senderEndpoint.Task.WaitAsync(Timeout); + + await server.SendToAsync(clientEndpoint, new byte[] { 9, 9 }, CancellationToken.None); + + Assert.Equal([9, 9], await clientReceived.Task.WaitAsync(Timeout)); + } } From 55685343f2838b547a77edc3fb590052a3f454e9 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:09:59 +0200 Subject: [PATCH 03/98] feat(udp): add UdpSessionManager with per-endpoint sessions and idle-timeout sweep --- .../Sessions/UdpSessionConnection.cs | 42 +++ .../Sessions/UdpSessionEntry.cs | 17 ++ .../Sessions/UdpSessionManager.cs | 282 ++++++++++++++++++ .../Network/UdpSessionManagerTests.cs | 146 +++++++++ .../Support/FakeTimeProvider.cs | 37 +++ 5 files changed, 524 insertions(+) create mode 100644 src/SquidStd.Network/Sessions/UdpSessionConnection.cs create mode 100644 src/SquidStd.Network/Sessions/UdpSessionEntry.cs create mode 100644 src/SquidStd.Network/Sessions/UdpSessionManager.cs create mode 100644 tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs create mode 100644 tests/SquidStd.Tests/Support/FakeTimeProvider.cs diff --git a/src/SquidStd.Network/Sessions/UdpSessionConnection.cs b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs new file mode 100644 index 00000000..35755e24 --- /dev/null +++ b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs @@ -0,0 +1,42 @@ +using System.Net; +using SquidStd.Network.Interfaces.Client; +using SquidStd.Network.Server; + +namespace SquidStd.Network.Sessions; + +/// +/// Virtual per-endpoint connection backing a UDP session. Sends route to the server's +/// ; closing removes the session via a callback. +/// +internal sealed class UdpSessionConnection : INetworkConnection +{ + private readonly SquidStdUdpServer _server; + private readonly IPEndPoint _remoteEndPoint; + private readonly Action _onClose; + private int _closed; + + public long SessionId { get; } + public EndPoint? RemoteEndPoint => _remoteEndPoint; + public bool IsConnected => Volatile.Read(ref _closed) == 0; + + public UdpSessionConnection(SquidStdUdpServer server, IPEndPoint remoteEndPoint, long sessionId, Action onClose) + { + _server = server; + _remoteEndPoint = remoteEndPoint; + SessionId = sessionId; + _onClose = onClose; + } + + public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + => _server.SendToAsync(_remoteEndPoint, payload, cancellationToken); + + public Task CloseAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _closed, 1) == 0) + { + _onClose(); + } + + return Task.CompletedTask; + } +} diff --git a/src/SquidStd.Network/Sessions/UdpSessionEntry.cs b/src/SquidStd.Network/Sessions/UdpSessionEntry.cs new file mode 100644 index 00000000..3d4d3903 --- /dev/null +++ b/src/SquidStd.Network/Sessions/UdpSessionEntry.cs @@ -0,0 +1,17 @@ +namespace SquidStd.Network.Sessions; + +/// +/// Internal registry entry pairing a UDP session with its last-activity timestamp. +/// +/// Application-defined per-connection state. +internal sealed class UdpSessionEntry +{ + public Session Session { get; } + public DateTimeOffset LastActivityUtc { get; set; } + + public UdpSessionEntry(Session session, DateTimeOffset lastActivityUtc) + { + Session = session; + LastActivityUtc = lastActivityUtc; + } +} diff --git a/src/SquidStd.Network/Sessions/UdpSessionManager.cs b/src/SquidStd.Network/Sessions/UdpSessionManager.cs new file mode 100644 index 00000000..4ccd022b --- /dev/null +++ b/src/SquidStd.Network/Sessions/UdpSessionManager.cs @@ -0,0 +1,282 @@ +using System.Collections.Concurrent; +using System.Net; +using Serilog; +using SquidStd.Network.Data.Events; +using SquidStd.Network.Interfaces.Client; +using SquidStd.Network.Interfaces.Sessions; +using SquidStd.Network.Server; + +namespace SquidStd.Network.Sessions; + +/// +/// Tracks per-endpoint UDP sessions over a . Sessions are created on +/// the first datagram from an endpoint and removed by idle-timeout sweep or explicit disconnect. +/// +/// Application-defined per-connection state. +public sealed class UdpSessionManager : ISessionManager, IDisposable +{ + private readonly ILogger _logger = Log.ForContext>(); + private readonly ConcurrentDictionary> _byEndpoint = new(); + private readonly ConcurrentDictionary _byId = new(); + private readonly Lock _createLock = new(); + private readonly SquidStdUdpServer _server; + private readonly Func _stateFactory; + private readonly TimeSpan _idleTimeout; + private readonly TimeProvider _timeProvider; + private readonly ITimer _sweepTimer; + private long _sessionIdSequence; + private int _disposed; + + /// + public int Count => _byEndpoint.Count; + + /// + public IReadOnlyCollection> Sessions + => _byEndpoint.Values.Select(entry => entry.Session).ToArray(); + + /// Idle period after which an inactive session is removed. + public TimeSpan IdleTimeout => _idleTimeout; + + /// + public event EventHandler>? OnSessionCreated; + + /// + public event EventHandler>? OnSessionRemoved; + + /// + public event EventHandler>? OnSessionData; + + public UdpSessionManager( + SquidStdUdpServer server, + Func stateFactory, + TimeSpan? idleTimeout = null, + TimeSpan? sweepInterval = null, + TimeProvider? timeProvider = null + ) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(stateFactory); + + _server = server; + _stateFactory = stateFactory; + _idleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30); + _timeProvider = timeProvider ?? TimeProvider.System; + + // Suppress the server's default echo while sessions are managed here. + _server.OnDatagram = static (_, _) => ReadOnlyMemory.Empty; + _server.OnDatagramReceived += HandleServerDatagram; + + var interval = sweepInterval ?? TimeSpan.FromSeconds(10); + _sweepTimer = _timeProvider.CreateTimer(_ => SafeSweep(), null, interval, interval); + } + + /// + public bool TryGetSession(long sessionId, out Session? session) + { + if (_byId.TryGetValue(sessionId, out var endPoint) && _byEndpoint.TryGetValue(endPoint, out var entry)) + { + session = entry.Session; + + return true; + } + + session = null; + + return false; + } + + /// Looks up a session by remote endpoint. + public bool TryGetSession(IPEndPoint endPoint, out Session? session) + { + if (_byEndpoint.TryGetValue(endPoint, out var entry)) + { + session = entry.Session; + + return true; + } + + session = null; + + return false; + } + + /// + public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => TryGetSession(sessionId, out var session) + ? session!.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + + /// Sends a payload to the session for the given endpoint. No-op when unknown. + public Task SendToAsync(IPEndPoint endPoint, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => TryGetSession(endPoint, out var session) + ? session!.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + + /// + public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + var snapshot = _byEndpoint.Values.ToArray(); + var tasks = new List(snapshot.Length); + + foreach (var entry in snapshot) + { + tasks.Add(SendSafelyAsync(entry.Session, payload, cancellationToken)); + } + + await Task.WhenAll(tasks); + } + + /// + public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken = default) + => TryGetSession(sessionId, out var session) + ? session!.CloseAsync(cancellationToken) + : Task.CompletedTask; + + /// Closes the session for the given endpoint. No-op when unknown. + public Task DisconnectAsync(IPEndPoint endPoint, CancellationToken cancellationToken = default) + => TryGetSession(endPoint, out var session) + ? session!.CloseAsync(cancellationToken) + : Task.CompletedTask; + + internal void HandleDatagram(IPEndPoint remoteEndPoint, ReadOnlyMemory data) + { + var entry = GetOrCreate(remoteEndPoint, out var created); + entry.LastActivityUtc = _timeProvider.GetUtcNow(); + + if (created) + { + RaiseSessionCreated(entry.Session); + } + + RaiseSessionData(entry.Session, data); + } + + internal void SweepExpiredSessions() + { + var now = _timeProvider.GetUtcNow(); + + foreach (var kvp in _byEndpoint.ToArray()) + { + if (now - kvp.Value.LastActivityUtc > _idleTimeout) + { + RemoveSession(kvp.Key); + } + } + } + + private UdpSessionEntry GetOrCreate(IPEndPoint remoteEndPoint, out bool created) + { + if (_byEndpoint.TryGetValue(remoteEndPoint, out var existing)) + { + created = false; + + return existing; + } + + lock (_createLock) + { + if (_byEndpoint.TryGetValue(remoteEndPoint, out existing)) + { + created = false; + + return existing; + } + + var sessionId = Interlocked.Increment(ref _sessionIdSequence); + var connection = new UdpSessionConnection(_server, remoteEndPoint, sessionId, () => RemoveSession(remoteEndPoint)); + var session = new Session(sessionId, connection, _stateFactory(connection), _timeProvider.GetUtcNow()); + var entry = new UdpSessionEntry(session, _timeProvider.GetUtcNow()); + + _byEndpoint[remoteEndPoint] = entry; + _byId[sessionId] = remoteEndPoint; + created = true; + + return entry; + } + } + + private void RemoveSession(IPEndPoint endPoint) + { + if (_byEndpoint.TryRemove(endPoint, out var entry)) + { + _byId.TryRemove(entry.Session.SessionId, out _); + RaiseSessionRemoved(entry.Session); + } + } + + private void SafeSweep() + { + try + { + SweepExpiredSessions(); + } + catch (Exception ex) + { + _logger.Error(ex, "UDP session sweep failed"); + } + } + + private async Task SendSafelyAsync(Session session, ReadOnlyMemory payload, CancellationToken cancellationToken) + { + try + { + await session.SendAsync(payload, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); + } + } + + private void HandleServerDatagram(object? sender, SquidStdUdpDatagramReceivedEventArgs e) + => HandleDatagram(e.RemoteEndPoint, e.Data); + + private void RaiseSessionCreated(Session session) + { + try + { + OnSessionCreated?.Invoke(this, new(session)); + } + catch (Exception ex) + { + _logger.Error(ex, "OnSessionCreated handler failed for session {SessionId}", session.SessionId); + } + } + + private void RaiseSessionRemoved(Session session) + { + try + { + OnSessionRemoved?.Invoke(this, new(session)); + } + catch (Exception ex) + { + _logger.Error(ex, "OnSessionRemoved handler failed for session {SessionId}", session.SessionId); + } + } + + private void RaiseSessionData(Session session, ReadOnlyMemory data) + { + try + { + OnSessionData?.Invoke(this, new(session, data)); + } + catch (Exception ex) + { + _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _server.OnDatagramReceived -= HandleServerDatagram; + _sweepTimer.Dispose(); + _byEndpoint.Clear(); + _byId.Clear(); + } +} diff --git a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs new file mode 100644 index 00000000..1ed3b8cf --- /dev/null +++ b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using SquidStd.Network.Server; +using SquidStd.Network.Sessions; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Network; + +public class UdpSessionManagerTests +{ + private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static SquidStdUdpServer NewServer() + => new(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + + private static UdpSessionManager NewManager(SquidStdUdpServer server, FakeTimeProvider time) + => new( + server, + connection => $"state-{connection.SessionId}", + idleTimeout: TimeSpan.FromSeconds(30), + sweepInterval: TimeSpan.FromSeconds(10), + timeProvider: time); + + private static IPEndPoint Peer(int port) + => new(IPAddress.Loopback, port); + + [Fact] + public void FirstDatagram_CreatesSessionAndRaisesCreatedThenData() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + Session? created = null; + var dataCount = 0; + manager.OnSessionCreated += (_, e) => created = e.Session; + manager.OnSessionData += (_, _) => dataCount++; + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + + Assert.Equal(1, manager.Count); + Assert.NotNull(created); + Assert.Equal("state-1", created!.State); + Assert.Equal(1, dataCount); + Assert.True(manager.TryGetSession(Peer(5000), out var byEp)); + Assert.True(manager.TryGetSession(created.SessionId, out var byId)); + Assert.Same(byEp, byId); + } + + [Fact] + public void SecondDatagram_SameEndpoint_RaisesDataOnly() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + var createdCount = 0; + var dataCount = 0; + manager.OnSessionCreated += (_, _) => createdCount++; + manager.OnSessionData += (_, _) => dataCount++; + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + manager.HandleDatagram(Peer(5000), new byte[] { 2 }); + + Assert.Equal(1, createdCount); + Assert.Equal(2, dataCount); + Assert.Equal(1, manager.Count); + } + + [Fact] + public void SweepExpiredSessions_RemovesIdleSessions() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + Session? removed = null; + manager.OnSessionRemoved += (_, e) => removed = e.Session; + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + time.Advance(TimeSpan.FromSeconds(31)); + manager.SweepExpiredSessions(); + + Assert.Equal(0, manager.Count); + Assert.NotNull(removed); + Assert.False(manager.TryGetSession(Peer(5000), out _)); + } + + [Fact] + public void SweepExpiredSessions_KeepsRecentlyActiveSessions() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + time.Advance(TimeSpan.FromSeconds(20)); + manager.HandleDatagram(Peer(5000), new byte[] { 2 }); // refresh activity + time.Advance(TimeSpan.FromSeconds(20)); // 20s since last activity < 30s + manager.SweepExpiredSessions(); + + Assert.Equal(1, manager.Count); + } + + [Fact] + public async Task DisconnectAsync_ByEndpoint_RemovesSessionOnce() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + var removals = 0; + manager.OnSessionRemoved += (_, _) => removals++; + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + + await manager.DisconnectAsync(Peer(5000), CancellationToken.None); + await manager.DisconnectAsync(Peer(5000), CancellationToken.None); // idempotent + + Assert.Equal(0, manager.Count); + Assert.Equal(1, removals); + } + + [Fact] + public async Task DisconnectAsync_UnknownId_IsNoOp() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + await manager.DisconnectAsync(999, CancellationToken.None); + // No exception = pass. + } + + [Fact] + public void Dispose_ClearsSessionsAndIsIdempotent() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + var manager = NewManager(server, time); + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + + manager.Dispose(); + manager.Dispose(); + + Assert.Equal(0, manager.Count); + } +} diff --git a/tests/SquidStd.Tests/Support/FakeTimeProvider.cs b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs new file mode 100644 index 00000000..cfff2cde --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Tests.Support; + +/// +/// Test TimeProvider with a manually advanced clock and an inert timer (so periodic sweeps +/// never fire on their own; tests invoke the sweep explicitly). +/// +public sealed class FakeTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTimeOffset start) + { + _utcNow = start; + } + + public override DateTimeOffset GetUtcNow() + => _utcNow; + + public void Advance(TimeSpan delta) + => _utcNow = _utcNow.Add(delta); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + => new InertTimer(); + + private sealed class InertTimer : ITimer + { + public bool Change(TimeSpan dueTime, TimeSpan period) + => true; + + public void Dispose() + { + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} From 882c132d03fcc89c67d86b426d59c6487f80953d Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:10:46 +0200 Subject: [PATCH 04/98] test(udp): add loopback integration test for UDP session lifecycle --- .../Network/UdpSessionManagerTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs index 1ed3b8cf..f0d2d9e2 100644 --- a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs +++ b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs @@ -1,4 +1,5 @@ using System.Net; +using SquidStd.Network.Client; using SquidStd.Network.Server; using SquidStd.Network.Sessions; using SquidStd.Tests.Support; @@ -143,4 +144,35 @@ public void Dispose_ClearsSessionsAndIsIdempotent() Assert.Equal(0, manager.Count); } + + [Fact] + public async Task Integration_DatagramCreatesSessionAndManagerCanReply() + { + var timeout = TimeSpan.FromSeconds(5); + + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + using var manager = new UdpSessionManager(server, c => $"state-{c.SessionId}"); + + var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var data = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); + manager.OnSessionData += (_, e) => data.TrySetResult(e.Data.ToArray()); + + await server.StartAsync(CancellationToken.None); + var serverPort = server.Port; + + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var clientReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.OnDataReceived += (_, e) => clientReceived.TrySetResult(e.Data.ToArray()); + await client.StartAsync(CancellationToken.None); + + await client.SendToAsync(new byte[] { 1, 2, 3 }, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); + + var session = await created.Task.WaitAsync(timeout); + Assert.Equal([1, 2, 3], await data.Task.WaitAsync(timeout)); + Assert.Equal(1, manager.Count); + + await session.SendAsync(new byte[] { 4, 5 }, CancellationToken.None); + Assert.Equal([4, 5], await clientReceived.Task.WaitAsync(timeout)); + } } From 4b2f3965927c6cb1155f4df5c50281afa33e9495 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:08:23 +0200 Subject: [PATCH 05/98] feat: add SquidStd bootstrap options --- .../Data/Bootstrap/SquidStdLoggerOptions.cs | 39 +++++++++++++++++++ .../Data/Bootstrap/SquidStdOptions.cs | 17 ++++++++ .../Types/SquidStdLogRollingIntervalType.cs | 37 ++++++++++++++++++ .../Bootstrap/SquidStdOptionsTests.cs | 29 ++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs create mode 100644 src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs create mode 100644 src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs create mode 100644 tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs new file mode 100644 index 00000000..e361a730 --- /dev/null +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs @@ -0,0 +1,39 @@ +using SquidStd.Core.Types; + +namespace SquidStd.Core.Data.Bootstrap; + +/// +/// Defines YAML-backed logger options for SquidStd bootstrap. +/// +public sealed class SquidStdLoggerOptions +{ + /// + /// Gets or sets the minimum logger level. + /// + public LogLevelType MinimumLevel { get; set; } = LogLevelType.Information; + + /// + /// Gets or sets whether console logging is enabled. + /// + public bool EnableConsole { get; set; } = true; + + /// + /// Gets or sets whether file logging is enabled. + /// + public bool EnableFile { get; set; } + + /// + /// Gets or sets the file log directory. Relative paths are resolved from the SquidStd root directory. + /// + public string LogDirectory { get; set; } = "logs"; + + /// + /// Gets or sets the file log name or rolling file pattern. + /// + public string FileName { get; set; } = "squidstd-.log"; + + /// + /// Gets or sets the file log rolling interval. + /// + public SquidStdLogRollingIntervalType RollingInterval { get; set; } = SquidStdLogRollingIntervalType.Day; +} diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs new file mode 100644 index 00000000..614508d6 --- /dev/null +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs @@ -0,0 +1,17 @@ +namespace SquidStd.Core.Data.Bootstrap; + +/// +/// Defines bootstrap-only options used to locate SquidStd runtime resources. +/// +public sealed class SquidStdOptions +{ + /// + /// Gets or sets the root directory for configuration, logs, and runtime data. + /// + public string RootDirectory { get; set; } = Directory.GetCurrentDirectory(); + + /// + /// Gets or sets the logical configuration name or YAML file name. + /// + public string ConfigName { get; set; } = "squidstd"; +} diff --git a/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs b/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs new file mode 100644 index 00000000..9aa4830c --- /dev/null +++ b/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Core.Types; + +/// +/// Defines file log rolling intervals. +/// +public enum SquidStdLogRollingIntervalType +{ + /// + /// Does not roll log files automatically. + /// + Infinite = 0, + + /// + /// Rolls log files every year. + /// + Year = 1, + + /// + /// Rolls log files every month. + /// + Month = 2, + + /// + /// Rolls log files every day. + /// + Day = 3, + + /// + /// Rolls log files every hour. + /// + Hour = 4, + + /// + /// Rolls log files every minute. + /// + Minute = 5 +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs new file mode 100644 index 00000000..3661cd21 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs @@ -0,0 +1,29 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Types; + +namespace SquidStd.Tests.Bootstrap; + +public class SquidStdOptionsTests +{ + [Fact] + public void SquidStdOptions_Defaults_AreUsable() + { + var options = new SquidStdOptions(); + + Assert.False(string.IsNullOrWhiteSpace(options.RootDirectory)); + Assert.Equal("squidstd", options.ConfigName); + } + + [Fact] + public void SquidStdLoggerOptions_Defaults_EnableConsoleAndDisableFile() + { + var options = new SquidStdLoggerOptions(); + + Assert.Equal(LogLevelType.Information, options.MinimumLevel); + Assert.True(options.EnableConsole); + Assert.False(options.EnableFile); + Assert.Equal("logs", options.LogDirectory); + Assert.Equal("squidstd-.log", options.FileName); + Assert.Equal(SquidStdLogRollingIntervalType.Day, options.RollingInterval); + } +} From 8f0e595e68d304af2b3d6d1d642829a9ce0aea40 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:10:27 +0200 Subject: [PATCH 06/98] feat: register logger config section --- .../RegisterDefaultServicesExtensions.cs | 2 ++ .../RegisterDefaultServicesExtensionsTests.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index ea1d6a58..bfe266b9 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -3,6 +3,7 @@ using SquidStd.Abstractions.Extensions.Config; using SquidStd.Abstractions.Extensions.Container; using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; @@ -71,6 +72,7 @@ public IContainer RegisterCoreServices(string configName, string configDirectory /// The same container for chaining. public IContainer RegisterDefaultCoreConfigSections() { + container.RegisterConfigSection("logger", static () => new SquidStdLoggerOptions(), -1000); container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index cb5fec9d..98a7df12 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -1,6 +1,7 @@ using DryIoc; using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Data.Internal.Services; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; @@ -85,4 +86,20 @@ public void RegisterDefaultCoreConfigSections_RegistersJobsAndTimerWheelMetadata Assert.False(container.IsRegistered()); Assert.False(container.IsRegistered()); } + + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersLoggerMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains( + entries, + entry => entry.SectionName == "logger" && entry.ConfigType == typeof(SquidStdLoggerOptions) + ); + Assert.False(container.IsRegistered()); + } } From cc29938532f2c99cfb2d06ba7890985783301b04 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:13:43 +0200 Subject: [PATCH 07/98] feat: add SquidStd bootstrap orchestrator --- .../Bootstrap/ISquidStdBootstrap.cs | 62 ++++ .../Services/SquidStdBootstrap.cs | 281 ++++++++++++++++++ .../Bootstrap/SquidStdBootstrapTests.cs | 216 ++++++++++++++ 3 files changed, 559 insertions(+) create mode 100644 src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs create mode 100644 src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs create mode 100644 tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs new file mode 100644 index 00000000..86e74330 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -0,0 +1,62 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; + +namespace SquidStd.Core.Interfaces.Bootstrap; + +/// +/// Coordinates SquidStd dependency registration, configuration loading, and service lifecycle. +/// +public interface ISquidStdBootstrap : IAsyncDisposable +{ + /// + /// Gets the bootstrap options used to configure runtime resources. + /// + SquidStdOptions Options { get; } + + /// + /// Gets the owned dependency injection container. + /// + IContainer Container { get; } + + /// + /// Applies custom service registrations before the bootstrap lifecycle starts. + /// + /// Callback that receives and returns the container. + /// The same bootstrap instance for chaining. + ISquidStdBootstrap ConfigureService(Func configure); + + /// + /// Applies custom service registrations before the bootstrap lifecycle starts. + /// + /// Callback that receives and returns the container. + /// The same bootstrap instance for chaining. + ISquidStdBootstrap ConfigureServices(Func configure); + + /// + /// Resolves a service from the owned dependency injection container. + /// + /// The service type to resolve. + /// The resolved service instance. + TService Resolve(); + + /// + /// Starts registered lifecycle services in priority order. + /// + /// Token used to cancel the start operation. + /// A task that represents the asynchronous start operation. + ValueTask StartAsync(CancellationToken cancellationToken = default); + + /// + /// Stops started lifecycle services in reverse priority order. + /// + /// Token used to cancel the stop operation. + /// A task that represents the asynchronous stop operation. + ValueTask StopAsync(CancellationToken cancellationToken = default); + + /// + /// Starts services, waits until cancellation, and then stops services. + /// + /// Token that controls the run lifetime. + /// A task that completes after services have stopped. + Task RunAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs new file mode 100644 index 00000000..4249a045 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs @@ -0,0 +1,281 @@ +using DryIoc; +using SquidStd.Abstractions.Data.Internal.Services; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Services.Core.Extensions; + +namespace SquidStd.Services.Core.Services; + +/// +/// Default SquidStd bootstrapper and service lifecycle orchestrator. +/// +public sealed class SquidStdBootstrap : ISquidStdBootstrap +{ + private readonly Lock _syncRoot = new(); + private readonly List _startedServices = []; + private int _disposed; + private BootstrapState _state; + + /// + /// Initializes a bootstrapper with default options. + /// + public SquidStdBootstrap() + : this(new SquidStdOptions()) + { + } + + /// + /// Initializes a bootstrapper with the specified options. + /// + /// Bootstrap options used to register core services. + public SquidStdBootstrap(SquidStdOptions options) + : this(options, new Container()) + { + } + + private SquidStdBootstrap(SquidStdOptions options, IContainer container) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(container); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ConfigName); + ArgumentException.ThrowIfNullOrWhiteSpace(options.RootDirectory); + + Options = options; + Container = container; + + Container.RegisterInstance(this, IfAlreadyRegistered.Replace); + Container.RegisterInstance(this, IfAlreadyRegistered.Replace); + Container.RegisterInstance(Options, IfAlreadyRegistered.Replace); + Container.RegisterCoreServices(Options.ConfigName, Options.RootDirectory); + } + + /// + public SquidStdOptions Options { get; } + + /// + public IContainer Container { get; } + + /// + /// Creates a bootstrapper with default options. + /// + /// The created bootstrapper. + public static SquidStdBootstrap Create() + => new(); + + /// + /// Creates a bootstrapper with the specified options. + /// + /// Bootstrap options used to register core services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdOptions options) + => new(options); + + /// + public ISquidStdBootstrap ConfigureService(Func configure) + => ConfigureServices(configure); + + /// + public ISquidStdBootstrap ConfigureServices(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + ThrowIfDisposed(); + + lock (_syncRoot) + { + if (_state != BootstrapState.Created) + { + throw new InvalidOperationException("Services cannot be configured after bootstrap start."); + } + } + + var configuredContainer = configure(Container); + + if (!ReferenceEquals(configuredContainer, Container)) + { + throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); + } + + return this; + } + + /// + public TService Resolve() + { + ThrowIfDisposed(); + + return Container.Resolve(); + } + + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + MarkStarting(); + + try + { + var registrations = GetServiceRegistrations(); + var startedInstances = new HashSet(ReferenceEqualityComparer.Instance); + + for (var i = 0; i < registrations.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var registration = registrations[i]; + var instance = Container.Resolve(registration.ServiceType); + + if (instance is not ISquidStdService service || !startedInstances.Add(service)) + { + continue; + } + + await service.StartAsync(cancellationToken); + _startedServices.Add(service); + } + } + catch + { + MarkCreated(); + + throw; + } + } + + /// + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + if (!MarkStopping()) + { + return; + } + + try + { + for (var i = _startedServices.Count - 1; i >= 0; i--) + { + cancellationToken.ThrowIfCancellationRequested(); + await _startedServices[i].StopAsync(cancellationToken); + } + } + finally + { + _startedServices.Clear(); + } + } + + /// + public async Task RunAsync(CancellationToken cancellationToken = default) + { + await StartAsync(cancellationToken); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + finally + { + await StopAsync(CancellationToken.None); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + await StopAsync(CancellationToken.None); + } + finally + { + Container.Dispose(); + } + } + + private ServiceRegistrationData[] GetServiceRegistrations() + { + if (!Container.IsRegistered>()) + { + return []; + } + + return + [ + .. Container.Resolve>() + .OrderBy(registration => registration.Priority) + .ThenBy(registration => registration.ServiceType.FullName, StringComparer.Ordinal) + .ThenBy(registration => registration.ImplementationType.FullName, StringComparer.Ordinal) + ]; + } + + private void MarkStarting() + { + lock (_syncRoot) + { + if (_state == BootstrapState.Started) + { + return; + } + + if (_state == BootstrapState.Stopped) + { + throw new InvalidOperationException("Bootstrap cannot be restarted after stop."); + } + + _state = BootstrapState.Started; + } + } + + private void MarkCreated() + { + lock (_syncRoot) + { + _state = BootstrapState.Created; + } + } + + private bool MarkStopping() + { + lock (_syncRoot) + { + if (_state == BootstrapState.Stopped) + { + return false; + } + + if (_state == BootstrapState.Created) + { + _state = BootstrapState.Stopped; + + return false; + } + + _state = BootstrapState.Stopped; + + return true; + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(SquidStdBootstrap)); + } + } + + private enum BootstrapState + { + Created, + Started, + Stopped + } +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs new file mode 100644 index 00000000..5417e693 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs @@ -0,0 +1,216 @@ +using DryIoc; +using SquidStd.Abstractions.Data.Internal.Services; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types; +using SquidStd.Services.Core.Services; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +public class SquidStdBootstrapTests +{ + [Fact] + public async Task Create_RegistersBootstrapAndDefaultServices() + { + using var temp = new TempDirectory(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + var resolved = bootstrap.Resolve(); + var configManager = bootstrap.Resolve(); + + Assert.Same(bootstrap, resolved); + Assert.Equal(temp.Path, bootstrap.Options.RootDirectory); + Assert.Equal(Path.Combine(temp.Path, "app.yaml"), configManager.ConfigPath); + } + + [Fact] + public async Task StartAsync_LoadsConfigBeforeResolvingRegisteredServices() + { + using var temp = new TempDirectory(); + File.WriteAllText( + temp.Combine("app.yaml"), + """ + logger: + MinimumLevel: Warning + EnableConsole: false + """ + ); + var events = new List(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + bootstrap.ConfigureServices( + container => + { + container.RegisterInstance(events); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(ConfigConsumerService), + typeof(ConfigConsumerService), + -10 + ) + ); + + return container; + } + ); + + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); + + Assert.Contains("config:Warning", events); + } + + [Fact] + public async Task StartAsync_OrdersServicesByPriorityAndStopAsync_ReversesOrder() + { + using var temp = new TempDirectory(); + var events = new List(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + bootstrap.ConfigureServices( + container => + { + container.RegisterInstance(events); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(EarlyTrackedService), + typeof(EarlyTrackedService), + -20 + ) + ); + container.AddToRegisterTypedList( + new ServiceRegistrationData(typeof(LateTrackedService), typeof(LateTrackedService), 40) + ); + + return container; + } + ); + + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); + + Assert.True(events.IndexOf("early:start") < events.IndexOf("late:start")); + Assert.True(events.IndexOf("late:stop") < events.IndexOf("early:stop")); + } + + [Fact] + public async Task RunAsync_StartsUntilCancellationThenStops() + { + using var temp = new TempDirectory(); + using var cancellation = new CancellationTokenSource(); + var state = new RunTrackedState(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + bootstrap.ConfigureService( + container => + { + container.RegisterInstance(state); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(RunTrackedService), + typeof(RunTrackedService), + -20 + ) + ); + + return container; + } + ); + + var runTask = bootstrap.RunAsync(cancellation.Token); + + await state.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); + await cancellation.CancelAsync(); + await runTask.WaitAsync(TimeSpan.FromSeconds(3)); + await state.Stopped.Task.WaitAsync(TimeSpan.FromSeconds(3)); + + Assert.True(state.Stopped.Task.IsCompletedSuccessfully); + } + + private sealed class ConfigConsumerService(SquidStdLoggerOptions options, List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add($"config:{options.MinimumLevel}"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } + + private sealed class EarlyTrackedService(List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add("early:start"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + events.Add("early:stop"); + + return ValueTask.CompletedTask; + } + } + + private sealed class LateTrackedService(List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add("late:start"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + events.Add("late:stop"); + + return ValueTask.CompletedTask; + } + } + + private sealed class RunTrackedState + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class RunTrackedService(RunTrackedState state) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + state.Started.SetResult(); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + state.Stopped.SetResult(); + + return ValueTask.CompletedTask; + } + } +} From 3c03f22c26efe8a15d279f6626d89ecadaeee80f Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:15:25 +0200 Subject: [PATCH 08/98] feat: configure bootstrap logging sinks --- .../Services/SquidStdBootstrap.cs | 86 +++++++++++++++++++ .../SquidStd.Services.Core.csproj | 2 + .../Bootstrap/SquidStdBootstrapTests.cs | 32 +++++++ 3 files changed, 120 insertions(+) diff --git a/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs index 4249a045..5b327b96 100644 --- a/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs @@ -1,8 +1,12 @@ using DryIoc; +using Serilog; +using Serilog.Events; using SquidStd.Abstractions.Data.Internal.Services; using SquidStd.Abstractions.Interfaces.Services; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types; using SquidStd.Services.Core.Extensions; namespace SquidStd.Services.Core.Services; @@ -15,6 +19,7 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap private readonly Lock _syncRoot = new(); private readonly List _startedServices = []; private int _disposed; + private bool _loggerConfigured; private BootstrapState _state; /// @@ -132,6 +137,11 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) await service.StartAsync(cancellationToken); _startedServices.Add(service); + + if (service is IConfigManagerService) + { + ConfigureLogger(); + } } } catch @@ -196,10 +206,51 @@ public async ValueTask DisposeAsync() } finally { + if (_loggerConfigured) + { + Log.CloseAndFlush(); + } + Container.Dispose(); } } + private void ConfigureLogger() + { + if (!Container.IsRegistered()) + { + return; + } + + var options = Container.Resolve(); + var loggerConfiguration = new LoggerConfiguration(); + + if (options.MinimumLevel != LogLevelType.None) + { + var minimumLevel = MapLogLevel(options.MinimumLevel); + loggerConfiguration.MinimumLevel.Is(minimumLevel); + + if (options.EnableConsole) + { + loggerConfiguration.WriteTo.Console(restrictedToMinimumLevel: minimumLevel); + } + + if (options.EnableFile) + { + loggerConfiguration.WriteTo.File( + ResolveLogPath(options), + rollingInterval: MapRollingInterval(options.RollingInterval), + restrictedToMinimumLevel: minimumLevel + ); + } + } + + var logger = loggerConfiguration.CreateLogger(); + Log.Logger = logger; + Container.RegisterInstance(logger, IfAlreadyRegistered.Replace); + _loggerConfigured = true; + } + private ServiceRegistrationData[] GetServiceRegistrations() { if (!Container.IsRegistered>()) @@ -216,6 +267,41 @@ .. Container.Resolve>() ]; } + private LogEventLevel MapLogLevel(LogLevelType level) + => level switch + { + LogLevelType.Trace => LogEventLevel.Verbose, + LogLevelType.Debug => LogEventLevel.Debug, + LogLevelType.Information => LogEventLevel.Information, + LogLevelType.Warning => LogEventLevel.Warning, + LogLevelType.Error => LogEventLevel.Error, + LogLevelType.Critical => LogEventLevel.Fatal, + _ => LogEventLevel.Fatal + }; + + private RollingInterval MapRollingInterval(SquidStdLogRollingIntervalType interval) + => interval switch + { + SquidStdLogRollingIntervalType.Year => RollingInterval.Year, + SquidStdLogRollingIntervalType.Month => RollingInterval.Month, + SquidStdLogRollingIntervalType.Day => RollingInterval.Day, + SquidStdLogRollingIntervalType.Hour => RollingInterval.Hour, + SquidStdLogRollingIntervalType.Minute => RollingInterval.Minute, + SquidStdLogRollingIntervalType.Infinite => RollingInterval.Infinite, + _ => RollingInterval.Day + }; + + private string ResolveLogPath(SquidStdLoggerOptions options) + { + var logDirectory = string.IsNullOrWhiteSpace(options.LogDirectory) ? "logs" : options.LogDirectory; + var fileName = string.IsNullOrWhiteSpace(options.FileName) ? "squidstd-.log" : options.FileName; + var directory = Path.IsPathRooted(logDirectory) + ? logDirectory + : Path.Combine(Options.RootDirectory, logDirectory); + + return Path.Combine(directory, fileName); + } + private void MarkStarting() { lock (_syncRoot) diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index fff271c4..a6b0a0ca 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -14,6 +14,8 @@ + + diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs index 5417e693..8c06585c 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs @@ -1,4 +1,5 @@ using DryIoc; +using Serilog; using SquidStd.Abstractions.Data.Internal.Services; using SquidStd.Abstractions.Extensions.Container; using SquidStd.Abstractions.Interfaces.Services; @@ -11,6 +12,7 @@ namespace SquidStd.Tests.Bootstrap; +[Collection(SerilogEventSinkCollection.Name)] public class SquidStdBootstrapTests { [Fact] @@ -143,6 +145,36 @@ public async Task RunAsync_StartsUntilCancellationThenStops() Assert.True(state.Stopped.Task.IsCompletedSuccessfully); } + [Fact] + public async Task StartAsync_ConfiguresFileSinkFromLoggerOptions() + { + using var temp = new TempDirectory(); + File.WriteAllText( + temp.Combine("app.yaml"), + """ + logger: + MinimumLevel: Information + EnableConsole: false + EnableFile: true + LogDirectory: logs + FileName: app.log + RollingInterval: Infinite + """ + ); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); + Log.CloseAndFlush(); + + var logPath = temp.Combine(Path.Combine("logs", "app.log")); + var content = File.ReadAllText(logPath); + + Assert.Contains("JobSystemService started", content); + } + private sealed class ConfigConsumerService(SquidStdLoggerOptions options, List events) : ISquidStdService { public ValueTask StartAsync(CancellationToken cancellationToken = default) From 017934c6ac30417dbfc01a96c5d97302883b55fa Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:21:12 +0200 Subject: [PATCH 09/98] fix(lua): align script engine lifecycle contract - make IScriptEngineService inherit the SquidStd lifecycle service contract - expose Lua engine name/version through LuaEngineConfig - update LuaScriptEngineService start/stop methods to lifecycle signatures --- .../Data/Config/LuaEngineConfig.cs | 9 ++++----- .../Interfaces/Scripts/IScriptEngineService.cs | 9 ++------- .../Services/LuaScriptEngineService.cs | 10 +++++----- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs index b65e4924..5a3821b0 100644 --- a/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs @@ -6,12 +6,11 @@ public sealed record LuaEngineConfig public string ScriptsDirectory { get; } public string EngineVersion { get; } - public LuaEngineConfig(string luarcDirectory, string scriptsDirectory, string engineVersion) - { - ArgumentException.ThrowIfNullOrWhiteSpace(luarcDirectory); - ArgumentException.ThrowIfNullOrWhiteSpace(scriptsDirectory); - ArgumentException.ThrowIfNullOrWhiteSpace(engineVersion); + public string EngineName { get; } + public LuaEngineConfig(string luarcDirectory, string scriptsDirectory, string engineName, string engineVersion) + { + EngineName = engineName; LuarcDirectory = luarcDirectory; ScriptsDirectory = scriptsDirectory; EngineVersion = engineVersion; diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs index 957a0fc5..5d8833d9 100644 --- a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs @@ -1,3 +1,4 @@ +using SquidStd.Abstractions.Interfaces.Services; using SquidStd.Scripting.Lua.Data.Scripts; namespace SquidStd.Scripting.Lua.Interfaces.Scripts; @@ -5,7 +6,7 @@ namespace SquidStd.Scripting.Lua.Interfaces.Scripts; /// /// Interface for the script engine service that manages Lua execution. /// -public interface IScriptEngineService +public interface IScriptEngineService : ISquidStdService { /// /// Delegate for handling script file change events. @@ -163,12 +164,6 @@ void AddManualModuleFunction( /// The delegate to register as a global function. void RegisterGlobalFunction(string name, Delegate func); - /// - /// Starts the script engine service asynchronously. - /// - /// - Task StartAsync(); - /// /// Converts a .NET method name to a Lua-compatible function name. /// diff --git a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs index 7526b3d1..a7eb182d 100644 --- a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs @@ -615,14 +615,14 @@ public void Reset() /// Stops the script engine asynchronously. /// /// A task representing the asynchronous operation. - public Task ShutdownAsync() - => Task.CompletedTask; + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; /// /// Starts the script engine asynchronously. /// /// A task representing the asynchronous operation. - public async Task StartAsync() + public async ValueTask StartAsync(CancellationToken cancellationToken = default) { if (_isInitialized) { @@ -640,8 +640,8 @@ public async Task StartAsync() // scanners (e.g. LuaComponentLoader) once the script is ready but before bootstrap runs. AfterModulesRegistered?.Invoke(LuaScript); - AddConstant("version", VersionUtils.GetVersion()); - AddConstant("engine", "Moongate"); + AddConstant("version", _engineConfig.EngineVersion); + AddConstant("engine", _engineConfig.EngineName); AddConstant("platform", PlatformUtils.GetCurrentPlatform().ToString()); _ = Task.Run(() => GenerateLuaMetaFileAsync(CancellationToken.None), CancellationToken.None); From 7773146510b5b5ff8a3b0002c7ed6515b200fb57 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:29:55 +0200 Subject: [PATCH 10/98] refactor(bootstrap): align SquidStdBootstrap with code conventions - move Options/Container properties above the constructors - move DisposeAsync to be the last method - extract nested BootstrapState enum to Types/BootstrapStateType (internal) --- .../Services/SquidStdBootstrap.cs | 80 +++++++++---------- .../Types/BootstrapStateType.cs | 16 ++++ 2 files changed, 53 insertions(+), 43 deletions(-) create mode 100644 src/SquidStd.Services.Core/Types/BootstrapStateType.cs diff --git a/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs index 5b327b96..a2cdfe5c 100644 --- a/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs @@ -8,6 +8,7 @@ using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Types; using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Types; namespace SquidStd.Services.Core.Services; @@ -20,7 +21,13 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap private readonly List _startedServices = []; private int _disposed; private bool _loggerConfigured; - private BootstrapState _state; + private BootstrapStateType _state; + + /// + public SquidStdOptions Options { get; } + + /// + public IContainer Container { get; } /// /// Initializes a bootstrapper with default options. @@ -55,12 +62,6 @@ private SquidStdBootstrap(SquidStdOptions options, IContainer container) Container.RegisterCoreServices(Options.ConfigName, Options.RootDirectory); } - /// - public SquidStdOptions Options { get; } - - /// - public IContainer Container { get; } - /// /// Creates a bootstrapper with default options. /// @@ -88,7 +89,7 @@ public ISquidStdBootstrap ConfigureServices(Func configu lock (_syncRoot) { - if (_state != BootstrapState.Created) + if (_state != BootstrapStateType.Created) { throw new InvalidOperationException("Services cannot be configured after bootstrap start."); } @@ -192,29 +193,6 @@ public async Task RunAsync(CancellationToken cancellationToken = default) } } - /// - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - try - { - await StopAsync(CancellationToken.None); - } - finally - { - if (_loggerConfigured) - { - Log.CloseAndFlush(); - } - - Container.Dispose(); - } - } - private void ConfigureLogger() { if (!Container.IsRegistered()) @@ -306,17 +284,17 @@ private void MarkStarting() { lock (_syncRoot) { - if (_state == BootstrapState.Started) + if (_state == BootstrapStateType.Started) { return; } - if (_state == BootstrapState.Stopped) + if (_state == BootstrapStateType.Stopped) { throw new InvalidOperationException("Bootstrap cannot be restarted after stop."); } - _state = BootstrapState.Started; + _state = BootstrapStateType.Started; } } @@ -324,7 +302,7 @@ private void MarkCreated() { lock (_syncRoot) { - _state = BootstrapState.Created; + _state = BootstrapStateType.Created; } } @@ -332,19 +310,19 @@ private bool MarkStopping() { lock (_syncRoot) { - if (_state == BootstrapState.Stopped) + if (_state == BootstrapStateType.Stopped) { return false; } - if (_state == BootstrapState.Created) + if (_state == BootstrapStateType.Created) { - _state = BootstrapState.Stopped; + _state = BootstrapStateType.Stopped; return false; } - _state = BootstrapState.Stopped; + _state = BootstrapStateType.Stopped; return true; } @@ -358,10 +336,26 @@ private void ThrowIfDisposed() } } - private enum BootstrapState + /// + public async ValueTask DisposeAsync() { - Created, - Started, - Stopped + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + await StopAsync(CancellationToken.None); + } + finally + { + if (_loggerConfigured) + { + Log.CloseAndFlush(); + } + + Container.Dispose(); + } } } diff --git a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs b/src/SquidStd.Services.Core/Types/BootstrapStateType.cs new file mode 100644 index 00000000..2b1d72ff --- /dev/null +++ b/src/SquidStd.Services.Core/Types/BootstrapStateType.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Services.Core.Types; + +/// +/// Lifecycle state of the SquidStd bootstrapper. +/// +internal enum BootstrapStateType +{ + /// Created but not started. + Created, + + /// Started and running. + Started, + + /// Stopped; cannot be restarted. + Stopped +} From 652ebcc5afd039c62219c642c41e3bb2cfe3b37e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:34:33 +0200 Subject: [PATCH 11/98] refactor(lua): replace dynamic constructor wrapper - replace the user-data constructor delegate with a MoonSharp callback - add Lua scripting coverage for the engine, loader, bridge, modules, and helpers - reference the Lua scripting project from the test project --- .../Services/LuaScriptEngineService.cs | 131 +++------- .../Lua/AddScriptModuleExtensionTests.cs | 44 ++++ .../Scripting/Lua/LuaEventBridgeTests.cs | 67 +++++ .../Scripting/Lua/LuaModuleTests.cs | 90 +++++++ .../Lua/LuaScriptEngineServiceTests.cs | 231 ++++++++++++++++++ .../Scripting/Lua/LuaScriptLoaderTests.cs | 55 +++++ .../Scripting/Lua/LuaTableReaderTests.cs | 47 ++++ .../Scripting/Lua/ScriptResultBuilderTests.cs | 31 +++ .../Scripting/Lua/TableExtensionsTests.cs | 39 +++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 10 files changed, 643 insertions(+), 93 deletions(-) create mode 100644 tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs diff --git a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs index a7eb182d..a1462986 100644 --- a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs @@ -748,14 +748,12 @@ private DynValue ConvertToLua(object? value) => value == null ? DynValue.Nil : DynValue.FromObject(LuaScript, value); /// - /// Creates a factory function that dynamically invokes the correct constructor. - /// Uses reflection to find the constructor matching the number of arguments passed from Lua. + /// Creates a Lua callback that invokes the constructor matching the number of arguments passed from Lua. /// - private Func CreateConstructorWrapper( + private DynValue CreateConstructorCallback( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type ) { - // Cache constructors by parameter count for performance var constructorsByParamCount = new Dictionary(); var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); @@ -765,94 +763,41 @@ private Func CreateConstructorWrapp constructorsByParamCount.TryAdd(paramCount, ctor); } - return (arg1, arg2, arg3, arg4) => - { - // Collect arguments - var rawArgs = new List(); - - if (arg1 != null) - { - rawArgs.Add(arg1); - } - - if (arg2 != null) - { - rawArgs.Add(arg2); - } - - if (arg3 != null) - { - rawArgs.Add(arg3); - } - - if (arg4 != null) - { - rawArgs.Add(arg4); - } - - var argCount = rawArgs.Count; - - // Find constructor with matching parameter count - if (constructorsByParamCount.TryGetValue(argCount, out var ctor)) - { - try - { - // Convert arguments to match constructor parameter types - var parameters = ctor.GetParameters(); - var convertedArgs = new object[argCount]; - - for (var i = 0; i < argCount; i++) - { - var paramType = parameters[i].ParameterType; - var argValue = rawArgs[i]; - - // Convert argument to the expected parameter type - if (argValue == null) - { - convertedArgs[i] = null; - } - else if (paramType.IsInstanceOfType(argValue)) - { - // No conversion needed - convertedArgs[i] = argValue; - } - else - { - // Convert using Convert.ChangeType (handles double -> float, etc.) - try - { - convertedArgs[i] = Convert.ChangeType( - argValue, - paramType, - CultureInfo.InvariantCulture - ); - } - catch - { - convertedArgs[i] = argValue; // Fallback to original value - } - } - } - - // Create instance with converted arguments - return Activator.CreateInstance(type, convertedArgs); - } - catch (Exception ex) - { - throw new ScriptRuntimeException( - $"Constructor of {type.Name} with {argCount} arguments failed: {ex.Message}", - ex - ); - } - } - - // No matching constructor found - var availableCtors = string.Join(", ", constructorsByParamCount.Keys.OrderBy(k => k)); - - throw new ScriptRuntimeException( - $"No constructor found for {type.Name} with {argCount} arguments. Available: {availableCtors}" - ); - }; + return DynValue.NewCallback( + (_, args) => + { + var argCount = args.Count; + + if (!constructorsByParamCount.TryGetValue(argCount, out var ctor)) + { + var availableCtors = string.Join(", ", constructorsByParamCount.Keys.OrderBy(k => k)); + + throw new ScriptRuntimeException( + $"No constructor found for {type.Name} with {argCount} arguments. Available: {availableCtors}" + ); + } + + try + { + var parameters = ctor.GetParameters(); + var convertedArgs = new object?[argCount]; + + for (var i = 0; i < argCount; i++) + { + convertedArgs[i] = ConvertFromLua(args[i], parameters[i].ParameterType); + } + + return ConvertToLua(Activator.CreateInstance(type, convertedArgs)); + } + catch (Exception ex) + { + throw new ScriptRuntimeException( + $"Constructor of {type.Name} with {argCount} arguments failed: {ex.Message}", + ex + ); + } + } + ); } /// @@ -1260,7 +1205,7 @@ private void LoadToUserData() if (publicConstructors.Length > 0) { // Instantiable type - use constructor wrapper for easier instance creation - var constructorWrapper = CreateConstructorWrapper(scriptUserData.UserType); + var constructorWrapper = CreateConstructorCallback(scriptUserData.UserType); LuaScript.Globals[scriptUserData.UserType.Name] = constructorWrapper; } else diff --git a/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs new file mode 100644 index 00000000..00ee9e12 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs @@ -0,0 +1,44 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Scripting.Lua.Data.Internal; +using SquidStd.Scripting.Lua.Extensions.Scripts; + +namespace SquidStd.Tests.Scripting.Lua; + +public class AddScriptModuleExtensionTests +{ + [Fact] + public void RegisterLuaUserData_AddsUserDataMetadata() + { + using var container = new Container(); + + container.RegisterLuaUserData(); + + var registration = Assert.Single(container.Resolve>()); + Assert.Equal(typeof(TestUserData), registration.UserType); + } + + [Fact] + public void RegisterScriptModule_AddsMetadataAndRegistersSingleton() + { + using var container = new Container(); + + container.RegisterScriptModule(); + + var registration = Assert.Single(container.Resolve>()); + Assert.Equal(typeof(TestModule), registration.ModuleType); + Assert.Same(container.Resolve(), container.Resolve()); + } + + [Fact] + public void RegisterLuaUserData_NullTypeThrows() + { + using var container = new Container(); + + Assert.Throws(() => container.RegisterLuaUserData(null!)); + } + + public sealed class TestUserData; + + public sealed class TestModule; +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs new file mode 100644 index 00000000..a66070dd --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs @@ -0,0 +1,67 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Services; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaEventBridgeTests +{ + [Fact] + public void Invoke_WithoutAttachThrows() + { + var script = new Script(); + var callback = script.DoString("return function(payload) return payload.name end").Function; + var bridge = new LuaEventBridge(); + + Assert.Throws(() => bridge.Invoke(callback, new Dictionary())); + } + + [Fact] + public void Invoke_ConvertsNestedPayloadToLuaTable() + { + var script = new Script(); + var bridge = new LuaEventBridge(); + bridge.Attach(script); + var callback = script.DoString( + """ + return function(payload) + return payload.actor.name .. ':' .. payload.values[2] + end + """ + ).Function; + + var result = bridge.Invoke( + callback, + new Dictionary + { + ["actor"] = new Dictionary { ["name"] = "squid" }, + ["values"] = new List { 3, 7 } + } + ); + + Assert.Equal("squid:7", result.String); + } + + [Fact] + public void Publish_InvokesRegisteredCallbacksCaseInsensitively() + { + var script = new Script(); + var bridge = new LuaEventBridge(); + bridge.Attach(script); + var callback = script.DoString( + """ + calls = 0 + captured = nil + return function(payload) + calls = calls + 1 + captured = payload.name + end + """ + ).Function; + + bridge.Register("Spawned", callback); + bridge.Publish("spawned", new Dictionary { ["name"] = "slime" }); + + Assert.Equal(1, (int)script.Globals.Get("calls").Number); + Assert.Equal("slime", script.Globals.Get("captured").String); + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs new file mode 100644 index 00000000..6eb5b921 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs @@ -0,0 +1,90 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Interfaces.Events; +using SquidStd.Scripting.Lua.Modules; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaModuleTests +{ + [Fact] + public void RandomModule_ChanceHandlesBoundaries() + { + var module = new RandomModule(); + + Assert.False(module.Chance(0)); + Assert.False(module.Chance(-1)); + Assert.True(module.Chance(100)); + Assert.True(module.Chance(101)); + } + + [Fact] + public void RandomModule_IntRejectsInvalidRange() + { + var module = new RandomModule(); + + Assert.Throws(() => module.Int(5, 4)); + } + + [Fact] + public void RandomModule_PickRejectsEmptyTable() + { + var module = new RandomModule(); + + Assert.Throws(() => module.Pick(new Table(new Script()))); + } + + [Fact] + public void RandomModule_WeightedRejectsEntriesWithoutPositiveWeight() + { + var script = new Script(); + var entries = script.DoString( + """ + return { + { value = 'a', weight = 0 }, + { value = 'b', weight = -3 } + } + """ + ).Table; + var module = new RandomModule(); + + Assert.Throws(() => module.Weighted(entries)); + } + + [Fact] + public void EventsModule_RegistersCallbackWithBridge() + { + var script = new Script(); + var bridge = new CapturingLuaEventBridge(); + var callback = script.DoString("return function() end").Function; + var module = new EventsModule(bridge); + + module.On("spawned", callback); + + Assert.Equal("spawned", bridge.EventName); + Assert.Same(callback, bridge.Callback); + } + + private sealed class CapturingLuaEventBridge : ILuaEventBridge + { + public Closure? Callback { get; private set; } + + public string? EventName { get; private set; } + + public void Attach(Script script) + { + } + + public DynValue Invoke(Closure callback, IReadOnlyDictionary payload) + => DynValue.Nil; + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + } + + public void Register(string eventName, Closure callback) + { + EventName = eventName; + Callback = callback; + } + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs new file mode 100644 index 00000000..4aa71e7c --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs @@ -0,0 +1,231 @@ +using MoonSharp.Interpreter; +using DryIoc; +using SquidStd.Core.Directories; +using SquidStd.Scripting.Lua.Data.Config; +using SquidStd.Scripting.Lua.Data.Internal; +using SquidStd.Scripting.Lua.Data.Scripts; +using SquidStd.Scripting.Lua.Interfaces.Events; +using SquidStd.Scripting.Lua.Services; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaScriptEngineServiceTests +{ + [Fact] + public void AddCallback_AndExecuteCallback_NormalizeNameAndPassArguments() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + object[]? captured = null; + + engine.AddCallback("onSpawn", args => captured = args); + engine.ExecuteCallback("on_spawn", "squid", 7); + + Assert.NotNull(captured); + Assert.Equal(["squid", 7], captured); + } + + [Fact] + public void AddConstant_MakesSimpleAndObjectValuesAvailableToLua() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + engine.AddConstant("engineName", "SquidStd"); + engine.AddConstant("limits", new LimitConfig("jobs", 4)); + + Assert.Equal("SquidStd", engine.ExecuteFunction("ENGINE_NAME").Data); + Assert.Equal("jobs:4", engine.ExecuteFunction("LIMITS.Name .. ':' .. LIMITS.Count").Data); + } + + [Fact] + public void AddManualModuleFunction_RegistersActionAndTypedFunction() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + object[]? captured = null; + + engine.AddManualModuleFunction("testModule", "captureValue", args => captured = args); + engine.AddManualModuleFunction("testModule", "doubleValue", value => value * 2); + + engine.ExecuteScript("test_module.capture_value('abc', 12)"); + var result = engine.ExecuteFunction("test_module.double_value(6)"); + + Assert.NotNull(captured); + Assert.Equal("abc", captured[0]); + Assert.Equal(12d, captured[1]); + Assert.Equal(12d, result.Data); + } + + [Fact] + public void ExecuteFunction_ReturnsSuccessForValidExpression() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + var result = engine.ExecuteFunction("2 + 3"); + + Assert.True(result.Success); + Assert.Equal(5d, result.Data); + } + + [Fact] + public void ExecuteFunction_RaisesScriptErrorForInvalidExpression() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + ScriptErrorInfo? captured = null; + engine.OnScriptError += (_, error) => captured = error; + + var result = engine.ExecuteFunction("missing.value"); + + Assert.False(result.Success); + Assert.NotNull(captured); + Assert.Contains("nil", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ExecuteScript_TracksCacheHitsAndMisses() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + engine.ExecuteScript("cached_value = 1"); + engine.ExecuteScript("cached_value = 1"); + + var metrics = engine.GetExecutionMetrics(); + Assert.Equal(1, metrics.CacheMisses); + Assert.Equal(1, metrics.CacheHits); + Assert.Equal(1, metrics.TotalScriptsCached); + } + + [Fact] + public void RegisterGlobalAndUnregisterGlobal_UpdateLuaGlobals() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + engine.RegisterGlobal("answer", 42); + + Assert.Equal(42d, engine.ExecuteFunction("answer").Data); + Assert.True(engine.UnregisterGlobal("answer")); + Assert.False(engine.UnregisterGlobal("answer")); + } + + [Fact] + public void AddSearchDirectory_AllowsRequireFromAdditionalDirectory() + { + using var temp = new TempDirectory(); + using var extra = new TempDirectory(); + using var container = new Container(); + File.WriteAllText(extra.Combine("feature.lua"), "return { value = 42 }"); + using var engine = CreateEngine(temp, container); + + engine.AddSearchDirectory(extra.Path); + + Assert.Equal(42d, engine.ExecuteFunction("require('feature').value").Data); + } + + [Fact] + public async Task StartAsync_AttachesEventBridgeAndAddsBootstrapConstants() + { + using var temp = new TempDirectory(); + using var container = new Container(); + var bridge = new CapturingLuaEventBridge(); + container.RegisterInstance(bridge); + using var engine = CreateEngine(temp, container); + object? hookArgument = null; + engine.AfterModulesRegistered += value => hookArgument = value; + + await engine.StartAsync(CancellationToken.None); + await engine.StartAsync(CancellationToken.None); + + var stats = engine.GetStats(); + Assert.True(stats.IsInitialized); + Assert.Same(engine.LuaScript, bridge.AttachedScript); + Assert.Same(engine.LuaScript, hookArgument); + Assert.Equal("1.0.0", engine.ExecuteFunction("VERSION").Data); + Assert.Equal("SquidStd", engine.ExecuteFunction("ENGINE").Data); + } + + [Fact] + public void RegisteredUserData_CanBeConstructedFromLuaWithMoreThanFourArguments() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine( + temp, + container, + loadedUserData: + [ + new() { UserType = typeof(FiveArgumentUserData) } + ] + ); + + var result = engine.LuaScript.DoString( + """ + local value = FiveArgumentUserData(1, 2, 3, 4, 5) + return value.Total + """ + ); + + Assert.Equal(15, (int)result.Number); + } + + private static LuaScriptEngineService CreateEngine( + TempDirectory temp, + IContainer container, + List? scriptModules = null, + List? loadedUserData = null + ) + { + var scriptsDirectory = temp.Combine("scripts"); + var luarcDirectory = temp.Combine("luarc"); + Directory.CreateDirectory(scriptsDirectory); + + return new( + new DirectoriesConfig(temp.Path, []), + container, + new LuaEngineConfig(luarcDirectory, scriptsDirectory, "SquidStd", "1.0.0"), + scriptModules, + loadedUserData + ); + } + + private sealed record LimitConfig(string Name, int Count); + + public sealed class FiveArgumentUserData(int first, int second, int third, int fourth, int fifth) + { + public int Total { get; } = first + second + third + fourth + fifth; + } + + private sealed class CapturingLuaEventBridge : ILuaEventBridge + { + public Script? AttachedScript { get; private set; } + + public void Attach(Script script) + => AttachedScript = script; + + public DynValue Invoke( + Closure callback, + IReadOnlyDictionary payload + ) + => DynValue.Nil; + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + } + + public void Register(string eventName, Closure callback) + { + } + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs new file mode 100644 index 00000000..ed3856c2 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs @@ -0,0 +1,55 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Loaders; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaScriptLoaderTests +{ + [Fact] + public void ScriptFileExists_FindsModulePathPatterns() + { + using var temp = new TempDirectory(); + Directory.CreateDirectory(temp.Combine("modules")); + Directory.CreateDirectory(temp.Combine(Path.Combine("modules", "inventory"))); + File.WriteAllText(temp.Combine("plain.lua"), "return 1"); + File.WriteAllText(temp.Combine(Path.Combine("modules", "combat.lua")), "return 2"); + File.WriteAllText(temp.Combine(Path.Combine("modules", "inventory", "init.lua")), "return 3"); + var loader = new LuaScriptLoader(temp.Path); + + Assert.True(loader.ScriptFileExists("plain")); + Assert.True(loader.ScriptFileExists("combat")); + Assert.True(loader.ScriptFileExists("inventory")); + Assert.False(loader.ScriptFileExists("missing")); + } + + [Fact] + public void LoadFile_LoadsContentFromFirstMatchingSearchDirectory() + { + using var first = new TempDirectory(); + using var second = new TempDirectory(); + File.WriteAllText(second.Combine("feature.lua"), "return 'loaded'"); + var loader = new LuaScriptLoader([first.Path, second.Path]); + + var content = loader.LoadFile("feature.lua", new Table(new Script())); + + Assert.Equal("return 'loaded'", content); + } + + [Fact] + public void AddSearchDirectory_AddsAdditionalLookupRoot() + { + using var first = new TempDirectory(); + using var second = new TempDirectory(); + File.WriteAllText(second.Combine("extra.lua"), "return 'extra'"); + var loader = new LuaScriptLoader(first.Path); + + loader.AddSearchDirectory(second.Path); + + Assert.True(loader.ScriptFileExists("extra")); + } + + [Fact] + public void Constructor_EmptySearchDirectoriesThrows() + => Assert.Throws(() => new LuaScriptLoader(Array.Empty())); +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs new file mode 100644 index 00000000..cfd6e8cc --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs @@ -0,0 +1,47 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Extensions; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaTableReaderTests +{ + [Fact] + public void Readers_ReturnTypedValues() + { + var script = new Script(); + var table = new Table(script); + table["name"] = "squid"; + table["count"] = 3; + table["ratio"] = 1.5; + table["enabled"] = true; + table["mode"] = "Second"; + + Assert.Equal("squid", LuaTableReader.GetString(table, "name")); + Assert.Equal(3, LuaTableReader.GetInt(table, "count")); + Assert.Equal(1.5f, LuaTableReader.GetFloat(table, "ratio")); + Assert.True(LuaTableReader.GetBool(table, "enabled")); + Assert.Equal(TestMode.Second, LuaTableReader.GetEnum(table, "mode", TestMode.First)); + } + + [Fact] + public void Readers_ReturnDefaultsForMissingOrWrongTypes() + { + var script = new Script(); + var table = new Table(script); + table["name"] = 10; + table["count"] = "wrong"; + table["mode"] = "Unknown"; + + Assert.Equal("default", LuaTableReader.GetString(table, "name", "default")); + Assert.Equal(7, LuaTableReader.GetInt(table, "count", 7)); + Assert.Equal(2.5f, LuaTableReader.GetFloat(table, "missingFloat", 2.5f)); + Assert.True(LuaTableReader.GetBool(table, "missingBool", true)); + Assert.Equal(TestMode.First, LuaTableReader.GetEnum(table, "mode", TestMode.First)); + } + + private enum TestMode + { + First = 1, + Second = 2 + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs new file mode 100644 index 00000000..06ffbd07 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs @@ -0,0 +1,31 @@ +using SquidStd.Scripting.Lua.Data.Scripts; + +namespace SquidStd.Tests.Scripting.Lua; + +public class ScriptResultBuilderTests +{ + [Fact] + public void CreateSuccess_BuildsSuccessfulResult() + { + var result = ScriptResultBuilder.CreateSuccess() + .WithMessage("ok") + .WithData(42) + .Build(); + + Assert.True(result.Success); + Assert.Equal("ok", result.Message); + Assert.Equal(42, result.Data); + } + + [Fact] + public void CreateError_BuildsFailedResult() + { + var result = ScriptResultBuilder.CreateError() + .WithMessage("failed") + .Build(); + + Assert.False(result.Success); + Assert.Equal("failed", result.Message); + Assert.Null(result.Data); + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs new file mode 100644 index 00000000..dd20bcbe --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs @@ -0,0 +1,39 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Extensions.Scripts; + +namespace SquidStd.Tests.Scripting.Lua; + +public class TableExtensionsTests +{ + [Fact] + public void ToProxy_DelegatesInterfaceCallsToLuaFunctions() + { + var script = new Script(); + var table = script.DoString( + """ + return { + Sum = function(left, right) + return left + right + end + } + """ + ).Table; + + var proxy = table.ToProxy(); + + Assert.Equal(7, proxy.Sum(3, 4)); + } + + [Fact] + public void ToProxy_MissingFunctionThrowsMissingMethodException() + { + var proxy = new Table(new Script()).ToProxy(); + + Assert.Throws(() => proxy.Sum(1, 2)); + } + + public interface ICalculator + { + int Sum(int left, int right); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index c11bb1d6..a2a941d8 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -28,6 +28,7 @@ + From 902a4a5b3d47c27abae1b5ba03368dca3f3576f3 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:35:50 +0200 Subject: [PATCH 12/98] chore: ignore CodeGraph index - add the local .codegraph index directory to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 10f58e87..92b52a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -589,3 +589,6 @@ definitions.lua dist/ benchmarks/.compare + +# CodeGraph local index +.codegraph/ From bff33a42e66628108014caed31fc1da7aaa5c96f Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:50:22 +0200 Subject: [PATCH 13/98] refactor(services): align core and lua services with code conventions - ConfigManagerService: move properties above the constructor; drop ThrowIfNull on the DI container - EventBusService: extract nested EventDispatch to Internal/EventDispatch.cs; move Dispose last - JobSystemService: move properties above the constructor; move Dispose last; rename Schedule/Schedule to ScheduleAsync (IJobSystem + tests updated) - LuaScriptEngineService: reorder members (const first, fields, properties, events, ctor); move Dispose last; drop ThrowIfNull on DI deps; add CancellationToken to ExecuteFunctionAsync/ExecuteScriptFileAsync - IScriptEngineService: add CancellationToken to ExecuteFunctionAsync --- .../Interfaces/Jobs/IJobSystem.cs | 4 +- .../Scripts/IScriptEngineService.cs | 3 +- .../Services/LuaScriptEngineService.cs | 144 +++++++++--------- .../{ => Bootstrap}/SquidStdBootstrap.cs | 0 .../Services/ConfigManagerService.cs | 25 ++- .../Services/EventBusService.cs | 64 ++------ .../Services/Internal/EventDispatch.cs | 37 +++++ .../Services/JobSystemService.cs | 40 ++--- .../Services/Core/JobSystemServiceTests.cs | 32 ++-- 9 files changed, 176 insertions(+), 173 deletions(-) rename src/SquidStd.Services.Core/Services/{ => Bootstrap}/SquidStdBootstrap.cs (100%) create mode 100644 src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs diff --git a/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs b/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs index 063c749e..79c5336e 100644 --- a/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs +++ b/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs @@ -31,7 +31,7 @@ public interface IJobSystem : IDisposable /// Work invoked on a worker thread. /// Token used to cancel the job before it starts. /// A task that completes when the job finishes. - Task Schedule(Action work, CancellationToken cancellationToken = default); + Task ScheduleAsync(Action work, CancellationToken cancellationToken = default); /// /// Schedules work on a worker thread and returns the result. @@ -40,5 +40,5 @@ public interface IJobSystem : IDisposable /// Token used to cancel the job before it starts. /// The result type. /// A task that completes with the job result. - Task Schedule(Func work, CancellationToken cancellationToken = default); + Task ScheduleAsync(Func work, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs index 5d8833d9..6e3e3911 100644 --- a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs @@ -123,8 +123,9 @@ void AddManualModuleFunction( /// Asynchronously executes a Lua function or expression and returns the result. /// /// The Lua function call or expression to execute. + /// Token used to cancel the operation. /// A task containing a ScriptResult with the execution outcome. - Task ExecuteFunctionAsync(string command); + Task ExecuteFunctionAsync(string command, CancellationToken cancellationToken = default); /// /// Executes a function defined in the bootstrap script. diff --git a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs index a1462986..4afb9ff3 100644 --- a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs @@ -35,42 +35,58 @@ namespace SquidStd.Scripting.Lua.Services; /// public class LuaScriptEngineService : IScriptEngineService, IDisposable { - private static readonly string[] _completionExcludedGlobals = ["delay", "toString"]; - - private readonly LuaEngineConfig _engineConfig; - - public event IScriptEngineService.LuaFileChangedHandler? FileChanged; - private const string OnReadyFunctionName = "on_ready"; - private const string OnEngineRunFunctionName = "on_initialize"; - // Thread-safe collections + private static readonly string[] _completionExcludedGlobals = ["delay", "toString"]; + + private readonly LuaEngineConfig _engineConfig; private readonly ConcurrentDictionary> _callbacks = new(); private readonly ConcurrentDictionary _constants = new(); private readonly ConcurrentDictionary> _manualModuleFunctions = new(); - private readonly DirectoriesConfig _directoriesConfig; private readonly List _initScripts; private readonly ConcurrentDictionary _loadedModules = new(); private readonly ILogger _logger = Log.ForContext(); - - // Script caching - using hash to avoid re-parsing identical scripts private readonly ConcurrentDictionary _scriptCache = new(); private readonly List _scriptModules; private readonly List _loadedUserData; - private readonly IContainer _serviceProvider; + private int _cacheHits; private int _cacheMisses; - private bool _disposed; private bool _isInitialized; private Func _nameResolver; private LuaScriptLoader _scriptLoader; - private FileSystemWatcher? _watcher; + /// + /// Gets the MoonSharp script instance. + /// + public Script LuaScript { get; } + + /// + /// Gets the script engine instance. + /// + public object Engine => LuaScript; + + /// + /// Raised when a watched Lua file changes. + /// + public event IScriptEngineService.LuaFileChangedHandler? FileChanged; + + /// + /// Event raised when a script error occurs + /// + public event EventHandler? OnScriptError; + + /// + public event Action? AfterModulesRegistered; + + /// + public event Action? OnComponentFileChanged; + /// /// Initializes a new instance of the LuaScriptEngineService class. /// @@ -78,7 +94,7 @@ public class LuaScriptEngineService : IScriptEngineService, IDisposable /// The list of script modules. /// The list of loaded user data. /// The service provider. - /// The version service. + /// The Lua engine configuration. public LuaScriptEngineService( DirectoriesConfig directoriesConfig, IContainer serviceProvider, @@ -92,9 +108,6 @@ public LuaScriptEngineService( scriptModules ??= new(); loadedUserData ??= new(); - ArgumentNullException.ThrowIfNull(directoriesConfig); - ArgumentNullException.ThrowIfNull(serviceProvider); - _scriptModules = scriptModules; _directoriesConfig = directoriesConfig; _serviceProvider = serviceProvider; @@ -109,27 +122,6 @@ public LuaScriptEngineService( LoadToUserData(); } - /// - /// Gets the MoonSharp script instance. - /// - public Script LuaScript { get; } - - /// - /// Event raised when a script error occurs - /// - public event EventHandler? OnScriptError; - - /// - public event Action? AfterModulesRegistered; - - /// - public event Action? OnComponentFileChanged; - - /// - /// Gets the script engine instance. - /// - public object Engine => LuaScript; - /// /// Adds a callback function that can be called from Lua scripts. /// @@ -294,36 +286,6 @@ public void ClearScriptCache() _logger.Information("Script cache cleared"); } - /// - /// Disposes of the resources used by the LuaScriptEngineService. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - try - { - _loadedModules.Clear(); - _callbacks.Clear(); - _constants.Clear(); - - GC.SuppressFinalize(this); - - _logger.Debug("Lua engine disposed successfully"); - } - catch (Exception ex) - { - _logger.Warning(ex, "Error during Lua engine disposal"); - } - finally - { - _disposed = true; - } - } - /// /// Executes a registered callback with the specified arguments. /// @@ -425,9 +387,14 @@ public ScriptResult ExecuteFunction(string command) /// Executes a Lua function asynchronously and returns the result. /// /// The function command to execute. + /// Token used to cancel the operation. /// A task representing the asynchronous operation. - public async Task ExecuteFunctionAsync(string command) - => ExecuteFunction(command); + public Task ExecuteFunctionAsync(string command, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + return Task.FromResult(ExecuteFunction(command)); + } public void ExecuteFunctionFromBootstrap(string name) { @@ -501,8 +468,9 @@ public void ExecuteScriptFile(string scriptFile) /// Executes a script file asynchronously. /// /// The path to the script file. + /// Token used to cancel the operation. /// A task representing the asynchronous operation. - public async Task ExecuteScriptFileAsync(string scriptFile) + public async Task ExecuteScriptFileAsync(string scriptFile, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(scriptFile); @@ -513,7 +481,7 @@ public async Task ExecuteScriptFileAsync(string scriptFile) try { - var content = await File.ReadAllTextAsync(scriptFile).ConfigureAwait(false); + var content = await File.ReadAllTextAsync(scriptFile, cancellationToken).ConfigureAwait(false); _logger.Debug("Executing script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); ExecuteScript(content, scriptFile); } @@ -1497,4 +1465,34 @@ private async Task RegisterScriptModulesAsync(CancellationToken cancellationToke RegisterEnums(); } + + /// + /// Disposes of the resources used by the LuaScriptEngineService. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + _loadedModules.Clear(); + _callbacks.Clear(); + _constants.Clear(); + + GC.SuppressFinalize(this); + + _logger.Debug("Lua engine disposed successfully"); + } + catch (Exception ex) + { + _logger.Warning(ex, "Error during Lua engine disposal"); + } + finally + { + _disposed = true; + } + } } diff --git a/src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs similarity index 100% rename from src/SquidStd.Services.Core/Services/SquidStdBootstrap.cs rename to src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index b1221ced..2b6c0032 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -15,6 +15,18 @@ public sealed class ConfigManagerService : IConfigManagerService, ISquidStdServi private readonly Dictionary _values = []; private int _started; + /// + public string ConfigName { get; } + + /// + public string ConfigDirectory { get; } + + /// + public string ConfigPath { get; } + + /// + public IReadOnlyCollection Entries => GetEntries(); + /// /// Initializes the config manager service. /// @@ -23,7 +35,6 @@ public sealed class ConfigManagerService : IConfigManagerService, ISquidStdServi /// Directory where the configuration file is searched. public ConfigManagerService(IContainer container, string configName, string configDirectory) { - ArgumentNullException.ThrowIfNull(container); ArgumentException.ThrowIfNullOrWhiteSpace(configName); ArgumentException.ThrowIfNullOrWhiteSpace(configDirectory); @@ -33,18 +44,6 @@ public ConfigManagerService(IContainer container, string configName, string conf ConfigPath = ResolveConfigPath(configName, ConfigDirectory); } - /// - public string ConfigName { get; } - - /// - public string ConfigDirectory { get; } - - /// - public string ConfigPath { get; } - - /// - public IReadOnlyCollection Entries => GetEntries(); - /// public string Compose() => YamlUtils.SerializeSections(BuildSectionMap()); diff --git a/src/SquidStd.Services.Core/Services/EventBusService.cs b/src/SquidStd.Services.Core/Services/EventBusService.cs index 1f3d5a3c..8d969a26 100644 --- a/src/SquidStd.Services.Core/Services/EventBusService.cs +++ b/src/SquidStd.Services.Core/Services/EventBusService.cs @@ -1,5 +1,6 @@ using System.Threading.Channels; using SquidStd.Core.Interfaces.Events; +using SquidStd.Services.Core.Services.Internal; namespace SquidStd.Services.Core.Services; @@ -30,54 +31,6 @@ public EventBusService() _dispatcher = Task.Run(ProcessDispatchesAsync); } - private sealed class EventDispatch - { - private readonly CancellationToken _cancellationToken; - private readonly Func _dispatch; - private readonly TaskCompletionSource _completion; - - public Task Completion => _completion.Task; - - public EventDispatch(Func dispatch, CancellationToken cancellationToken) - { - _dispatch = dispatch; - _cancellationToken = cancellationToken; - _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); - } - - public async Task ExecuteAsync() - { - try - { - await _dispatch(); - _completion.TrySetResult(); - } - catch (OperationCanceledException) - { - _completion.TrySetCanceled(_cancellationToken); - } - catch (Exception exception) - { - _completion.TrySetException(exception); - } - } - } - - /// - /// Stops the internal dispatcher. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - _dispatches.Writer.TryComplete(); - _dispatcher.GetAwaiter().GetResult(); - } - /// public void Publish(TEvent eventData) where TEvent : IEvent @@ -202,4 +155,19 @@ private void ThrowIfDisposed() throw new ObjectDisposedException(nameof(EventBusService)); } } + + /// + /// Stops the internal dispatcher. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _dispatches.Writer.TryComplete(); + _dispatcher.GetAwaiter().GetResult(); + } } diff --git a/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs b/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs new file mode 100644 index 00000000..751616c8 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// A queued event dispatch with a completion signal, executed by the event bus dispatcher loop. +/// +internal sealed class EventDispatch +{ + private readonly CancellationToken _cancellationToken; + private readonly Func _dispatch; + private readonly TaskCompletionSource _completion; + + public Task Completion => _completion.Task; + + public EventDispatch(Func dispatch, CancellationToken cancellationToken) + { + _dispatch = dispatch; + _cancellationToken = cancellationToken; + _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public async Task ExecuteAsync() + { + try + { + await _dispatch(); + _completion.TrySetResult(); + } + catch (OperationCanceledException) + { + _completion.TrySetCanceled(_cancellationToken); + } + catch (Exception exception) + { + _completion.TrySetException(exception); + } + } +} diff --git a/src/SquidStd.Services.Core/Services/JobSystemService.cs b/src/SquidStd.Services.Core/Services/JobSystemService.cs index 95253075..07ae39d1 100644 --- a/src/SquidStd.Services.Core/Services/JobSystemService.cs +++ b/src/SquidStd.Services.Core/Services/JobSystemService.cs @@ -23,6 +23,18 @@ public sealed class JobSystemService : IJobSystem, ISquidStdService private int _pendingCount; private int _started; + /// + public int ActiveCount => Volatile.Read(ref _activeCount); + + /// + public long CompletedCount => Interlocked.Read(ref _completedCount); + + /// + public int PendingCount => Volatile.Read(ref _pendingCount); + + /// + public int WorkerCount { get; } + /// /// Initializes the job system service. /// @@ -43,25 +55,7 @@ public JobSystemService(JobsConfig config) } /// - public int ActiveCount => Volatile.Read(ref _activeCount); - - /// - public long CompletedCount => Interlocked.Read(ref _completedCount); - - /// - public int PendingCount => Volatile.Read(ref _pendingCount); - - /// - public int WorkerCount { get; } - - /// - /// Releases worker resources. - /// - public void Dispose() - => Stop(CancellationToken.None); - - /// - public Task Schedule(Action work, CancellationToken cancellationToken = default) + public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(work); ThrowIfDisposed(); @@ -106,7 +100,7 @@ public Task Schedule(Action work, CancellationToken cancellationToken = default) } /// - public Task Schedule(Func work, CancellationToken cancellationToken = default) + public Task ScheduleAsync(Func work, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(work); ThrowIfDisposed(); @@ -317,4 +311,10 @@ private void WorkerLoop() _logger.Error(ex, "JobSystemService worker loop terminated unexpectedly"); } } + + /// + /// Releases worker resources. + /// + public void Dispose() + => Stop(CancellationToken.None); } diff --git a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs index 5dc4e4ee..dd796748 100644 --- a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs @@ -6,33 +6,33 @@ namespace SquidStd.Tests.Services.Core; public class JobSystemServiceTests { [Fact] - public async Task Schedule_Action_RunsAndCompletes() + public async Task ScheduleAsync_Action_RunsAndCompletes() { using var jobs = NewService(1); IJobSystem system = jobs; var called = false; await jobs.StartAsync(CancellationToken.None); - await system.Schedule(() => called = true); + await system.ScheduleAsync(() => called = true); Assert.True(called); Assert.Equal(1, system.CompletedCount); } [Fact] - public async Task Schedule_Func_ReturnsValue() + public async Task ScheduleAsync_Func_ReturnsValue() { using var jobs = NewService(2); IJobSystem system = jobs; await jobs.StartAsync(CancellationToken.None); - var result = await system.Schedule(() => 42); + var result = await system.ScheduleAsync(() => 42); Assert.Equal(42, result); } [Fact] - public async Task Schedule_ManyJobs_AllComplete() + public async Task ScheduleAsync_ManyJobs_AllComplete() { using var jobs = NewService(4); IJobSystem system = jobs; @@ -45,7 +45,7 @@ public async Task Schedule_ManyJobs_AllComplete() { var value = i; tasks.Add( - system.Schedule( + system.ScheduleAsync( () => { lock (sync) @@ -64,19 +64,19 @@ public async Task Schedule_ManyJobs_AllComplete() } [Fact] - public async Task Schedule_ThrowingAction_PropagatesExceptionToAwaiter() + public async Task ScheduleAsync_ThrowingAction_PropagatesExceptionToAwaiter() { using var jobs = NewService(1); IJobSystem system = jobs; await jobs.StartAsync(CancellationToken.None); await Assert.ThrowsAsync( - () => system.Schedule(() => throw new InvalidOperationException("boom")) + () => system.ScheduleAsync(() => throw new InvalidOperationException("boom")) ); } [Fact] - public async Task Schedule_TokenAlreadyCancelled_ReturnsCanceledTask() + public async Task ScheduleAsync_TokenAlreadyCancelled_ReturnsCanceledTask() { using var jobs = NewService(1); IJobSystem system = jobs; @@ -84,14 +84,14 @@ public async Task Schedule_TokenAlreadyCancelled_ReturnsCanceledTask() await jobs.StartAsync(CancellationToken.None); await cancellationTokenSource.CancelAsync(); - var task = system.Schedule(() => { }, cancellationTokenSource.Token); + var task = system.ScheduleAsync(() => { }, cancellationTokenSource.Token); await Assert.ThrowsAsync(() => task); Assert.Equal(TaskStatus.Canceled, task.Status); } [Fact] - public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() + public async Task ScheduleAsync_TokenCancelledBeforePickup_TransitionsToCanceled() { using var jobs = NewService(1); IJobSystem system = jobs; @@ -100,7 +100,7 @@ public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() using var cancellationTokenSource = new CancellationTokenSource(); await jobs.StartAsync(CancellationToken.None); - _ = system.Schedule( + _ = system.ScheduleAsync( () => { firstStarted.Set(); @@ -109,7 +109,7 @@ public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() ); Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(2)), "first job did not start"); - var task = system.Schedule(() => Assert.Fail("queued job should not run"), cancellationTokenSource.Token); + var task = system.ScheduleAsync(() => Assert.Fail("queued job should not run"), cancellationTokenSource.Token); await cancellationTokenSource.CancelAsync(); gate.Set(); @@ -126,7 +126,7 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() using var firstStarted = new ManualResetEventSlim(false); await jobs.StartAsync(CancellationToken.None); - _ = system.Schedule( + _ = system.ScheduleAsync( () => { firstStarted.Set(); @@ -134,7 +134,7 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() } ); Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(2)), "first job did not start"); - var queued = system.Schedule(() => Assert.Fail("queued job should not run")); + var queued = system.ScheduleAsync(() => Assert.Fail("queued job should not run")); await jobs.StopAsync(CancellationToken.None); gate.Set(); @@ -143,7 +143,7 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() Assert.Throws( () => { - _ = system.Schedule(() => { }); + _ = system.ScheduleAsync(() => { }); } ); jobs.Dispose(); From 6f66f015619fe3633633a604ead84324bb20ffcc Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 12:57:53 +0200 Subject: [PATCH 14/98] feat(metrics): add core metrics collection service - feat: add metric samples, snapshots, config entry, provider contract, and collection service - feat: register metrics in default core services and publish collection snapshots through EventBus - refactor: reuse logger level and rolling interval mapping extensions in bootstrap - test: cover metrics config, collection, EventBus publish, service registration, and logger mappings --- .../Data/Metrics/MetricSample.cs | 21 ++ .../Data/Metrics/MetricsCollectedEvent.cs | 9 + .../Data/Metrics/MetricsConfig.cs | 37 +++ .../Data/Metrics/MetricsSnapshot.cs | 28 ++ .../Extensions/Logger/LogLevelExtensions.cs | 1 + .../Interfaces/Metrics/IMetricProvider.cs | 21 ++ .../Metrics/IMetricsCollectionService.cs | 27 ++ src/SquidStd.Core/Types/Metrics/MetricType.cs | 22 ++ .../SquidStdLogRollingIntervalExtensions.cs | 27 ++ .../RegisterDefaultServicesExtensions.cs | 15 + .../Services/Bootstrap/SquidStdBootstrap.cs | 33 +-- .../Services/MetricsCollectionService.cs | 256 ++++++++++++++++++ .../Bootstrap/SquidStdBootstrapTests.cs | 1 + .../Logger/LogLevelExtensionsTests.cs | 9 +- .../Metrics/MetricsConfigTests.cs | 17 ++ .../Core/MetricsCollectionServiceTests.cs | 238 ++++++++++++++++ .../RegisterDefaultServicesExtensionsTests.cs | 42 +++ ...uidStdLogRollingIntervalExtensionsTests.cs | 24 ++ 18 files changed, 796 insertions(+), 32 deletions(-) create mode 100644 src/SquidStd.Core/Data/Metrics/MetricSample.cs create mode 100644 src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs create mode 100644 src/SquidStd.Core/Data/Metrics/MetricsConfig.cs create mode 100644 src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs create mode 100644 src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs create mode 100644 src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs create mode 100644 src/SquidStd.Core/Types/Metrics/MetricType.cs create mode 100644 src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs create mode 100644 src/SquidStd.Services.Core/Services/MetricsCollectionService.cs create mode 100644 tests/SquidStd.Tests/Metrics/MetricsConfigTests.cs create mode 100644 tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs create mode 100644 tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs diff --git a/src/SquidStd.Core/Data/Metrics/MetricSample.cs b/src/SquidStd.Core/Data/Metrics/MetricSample.cs new file mode 100644 index 00000000..b4612060 --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricSample.cs @@ -0,0 +1,21 @@ +using SquidStd.Core.Types.Metrics; + +namespace SquidStd.Core.Data.Metrics; + +/// +/// Represents one collected metric value. +/// +/// Metric name emitted by the provider. +/// Metric numeric value. +/// Optional timestamp for the metric value. +/// Optional dimensions associated with the value. +/// Metric semantic type. +/// Optional human-readable metric description. +public sealed record MetricSample( + string Name, + double Value, + DateTimeOffset? Timestamp = null, + IReadOnlyDictionary? Tags = null, + MetricType Type = MetricType.Gauge, + string? Help = null +); diff --git a/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs b/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs new file mode 100644 index 00000000..854a3f2b --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs @@ -0,0 +1,9 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Core.Data.Metrics; + +/// +/// Event published after a metrics snapshot has been collected. +/// +/// The collected metrics snapshot. +public sealed record MetricsCollectedEvent(MetricsSnapshot Snapshot) : IEvent; diff --git a/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs new file mode 100644 index 00000000..5add62b2 --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs @@ -0,0 +1,37 @@ +using SquidStd.Core.Types; +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Core.Data.Metrics; + +/// +/// Configuration for periodic metrics collection. +/// +public sealed class MetricsConfig : IConfigEntry +{ + /// + /// Gets or sets whether metrics collection is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the collection interval in milliseconds. + /// + public int IntervalMilliseconds { get; set; } = 1000; + + /// + /// Gets or sets whether each provider collection is logged. + /// + public bool LogEnabled { get; set; } = true; + + /// + /// Gets or sets the log level used for metrics collection logs. + /// + public LogLevelType LogLevel { get; set; } = LogLevelType.Trace; + + string IConfigEntry.SectionName => "metrics"; + + Type IConfigEntry.ConfigType => typeof(MetricsConfig); + + object IConfigEntry.CreateDefault() + => new MetricsConfig(); +} diff --git a/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs b/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs new file mode 100644 index 00000000..f0343dba --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Core.Data.Metrics; + +/// +/// Stores the last collected metrics batch. +/// +public sealed class MetricsSnapshot +{ + /// + /// Initializes a metrics snapshot. + /// + /// Timestamp when the snapshot was collected. + /// Metrics keyed by their flattened metric name. + public MetricsSnapshot(DateTimeOffset collectedAt, IReadOnlyDictionary metrics) + { + CollectedAt = collectedAt; + Metrics = metrics; + } + + /// + /// Gets the timestamp when the snapshot was collected. + /// + public DateTimeOffset CollectedAt { get; } + + /// + /// Gets metrics keyed by their flattened metric name. + /// + public IReadOnlyDictionary Metrics { get; } +} diff --git a/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs b/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs index d0242047..4f79d5e5 100644 --- a/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs +++ b/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs @@ -21,6 +21,7 @@ public static LogEventLevel ToSerilogLogLevel(this LogLevelType logLevel) LogLevelType.Information => LogEventLevel.Information, LogLevelType.Warning => LogEventLevel.Warning, LogLevelType.Error => LogEventLevel.Error, + LogLevelType.Critical => LogEventLevel.Fatal, _ => LogEventLevel.Information }; } diff --git a/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs b/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs new file mode 100644 index 00000000..cd13980e --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs @@ -0,0 +1,21 @@ +using SquidStd.Core.Data.Metrics; + +namespace SquidStd.Core.Interfaces.Metrics; + +/// +/// Provides metric samples for one subsystem domain. +/// +public interface IMetricProvider +{ + /// + /// Gets the unique provider name used as metric name prefix. + /// + string ProviderName { get; } + + /// + /// Collects the current metric samples for this provider. + /// + /// Token used to cancel collection. + /// The collected metric samples. + ValueTask> CollectAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs new file mode 100644 index 00000000..ef6c5e20 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Data.Metrics; + +namespace SquidStd.Core.Interfaces.Metrics; + +/// +/// Exposes the latest metrics snapshot collected from registered providers. +/// +public interface IMetricsCollectionService +{ + /// + /// Gets all metrics from the latest snapshot. + /// + /// Metrics keyed by their flattened metric name. + IReadOnlyDictionary GetAllMetrics(); + + /// + /// Gets the current metrics status. + /// + /// The current metrics snapshot. + MetricsSnapshot GetStatus(); + + /// + /// Gets the latest collected metrics snapshot. + /// + /// The current metrics snapshot. + MetricsSnapshot GetSnapshot(); +} diff --git a/src/SquidStd.Core/Types/Metrics/MetricType.cs b/src/SquidStd.Core/Types/Metrics/MetricType.cs new file mode 100644 index 00000000..f59560ff --- /dev/null +++ b/src/SquidStd.Core/Types/Metrics/MetricType.cs @@ -0,0 +1,22 @@ +namespace SquidStd.Core.Types.Metrics; + +/// +/// Defines the semantic type of a metric sample. +/// +public enum MetricType +{ + /// + /// A value that can go up or down. + /// + Gauge, + + /// + /// A cumulative value that only increases. + /// + Counter, + + /// + /// A value that represents a distribution bucket or aggregate. + /// + Histogram +} diff --git a/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs b/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs new file mode 100644 index 00000000..92d9d929 --- /dev/null +++ b/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs @@ -0,0 +1,27 @@ +using Serilog; +using SquidStd.Core.Types; + +namespace SquidStd.Services.Core.Extensions.Logger; + +/// +/// Extension methods for converting SquidStd logger options to Serilog values. +/// +public static class SquidStdLogRollingIntervalExtensions +{ + /// + /// Converts a SquidStd rolling interval to a Serilog rolling interval. + /// + /// The rolling interval to convert. + /// The corresponding Serilog rolling interval. + public static RollingInterval ToSerilogRollingInterval(this SquidStdLogRollingIntervalType interval) + => interval switch + { + SquidStdLogRollingIntervalType.Infinite => RollingInterval.Infinite, + SquidStdLogRollingIntervalType.Year => RollingInterval.Year, + SquidStdLogRollingIntervalType.Month => RollingInterval.Month, + SquidStdLogRollingIntervalType.Day => RollingInterval.Day, + SquidStdLogRollingIntervalType.Hour => RollingInterval.Hour, + SquidStdLogRollingIntervalType.Minute => RollingInterval.Minute, + _ => RollingInterval.Day + }; +} diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index bfe266b9..b3260253 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -5,10 +5,12 @@ using SquidStd.Abstractions.Extensions.Services; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; +using SquidStd.Core.Data.Metrics; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Jobs; +using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Threading; using SquidStd.Core.Interfaces.Timing; using SquidStd.Services.Core.Services; @@ -62,6 +64,7 @@ public IContainer RegisterCoreServices(string configName, string configDirectory container.RegisterJobSystemService(); container.RegisterMainThreadDispatcherService(); container.RegisterTimerWheelService(); + container.RegisterMetricsCollectionService(); return container; } @@ -75,6 +78,7 @@ public IContainer RegisterDefaultCoreConfigSections() container.RegisterConfigSection("logger", static () => new SquidStdLoggerOptions(), -1000); container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + container.RegisterConfigSection("metrics", static () => new MetricsConfig(), -80); return container; } @@ -97,6 +101,17 @@ public IContainer RegisterJobSystemService() return container.RegisterStdService(-1); } + /// + /// Registers the default metrics collection service in the container. + /// + /// The same container for chaining. + public IContainer RegisterMetricsCollectionService() + { + container.RegisterConfigSection("metrics", static () => new MetricsConfig(), -80); + + return container.RegisterStdService(1000); + } + /// /// Registers the default main-thread dispatcher service in the container. /// diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index a2cdfe5c..028783c2 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -1,16 +1,17 @@ using DryIoc; using Serilog; -using Serilog.Events; using SquidStd.Abstractions.Data.Internal.Services; using SquidStd.Abstractions.Interfaces.Services; using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Extensions.Logger; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Types; using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Extensions.Logger; using SquidStd.Services.Core.Types; -namespace SquidStd.Services.Core.Services; +namespace SquidStd.Services.Core.Services.Bootstrap; /// /// Default SquidStd bootstrapper and service lifecycle orchestrator. @@ -205,7 +206,7 @@ private void ConfigureLogger() if (options.MinimumLevel != LogLevelType.None) { - var minimumLevel = MapLogLevel(options.MinimumLevel); + var minimumLevel = options.MinimumLevel.ToSerilogLogLevel(); loggerConfiguration.MinimumLevel.Is(minimumLevel); if (options.EnableConsole) @@ -217,7 +218,7 @@ private void ConfigureLogger() { loggerConfiguration.WriteTo.File( ResolveLogPath(options), - rollingInterval: MapRollingInterval(options.RollingInterval), + rollingInterval: options.RollingInterval.ToSerilogRollingInterval(), restrictedToMinimumLevel: minimumLevel ); } @@ -245,30 +246,6 @@ .. Container.Resolve>() ]; } - private LogEventLevel MapLogLevel(LogLevelType level) - => level switch - { - LogLevelType.Trace => LogEventLevel.Verbose, - LogLevelType.Debug => LogEventLevel.Debug, - LogLevelType.Information => LogEventLevel.Information, - LogLevelType.Warning => LogEventLevel.Warning, - LogLevelType.Error => LogEventLevel.Error, - LogLevelType.Critical => LogEventLevel.Fatal, - _ => LogEventLevel.Fatal - }; - - private RollingInterval MapRollingInterval(SquidStdLogRollingIntervalType interval) - => interval switch - { - SquidStdLogRollingIntervalType.Year => RollingInterval.Year, - SquidStdLogRollingIntervalType.Month => RollingInterval.Month, - SquidStdLogRollingIntervalType.Day => RollingInterval.Day, - SquidStdLogRollingIntervalType.Hour => RollingInterval.Hour, - SquidStdLogRollingIntervalType.Minute => RollingInterval.Minute, - SquidStdLogRollingIntervalType.Infinite => RollingInterval.Infinite, - _ => RollingInterval.Day - }; - private string ResolveLogPath(SquidStdLoggerOptions options) { var logDirectory = string.IsNullOrWhiteSpace(options.LogDirectory) ? "logs" : options.LogDirectory; diff --git a/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs new file mode 100644 index 00000000..8bac0a6a --- /dev/null +++ b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs @@ -0,0 +1,256 @@ +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Extensions.Logger; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Types; + +namespace SquidStd.Services.Core.Services; + +/// +/// Periodically collects metrics from registered providers and stores the latest snapshot. +/// +public sealed class MetricsCollectionService : IMetricsCollectionService, ISquidStdService, IDisposable +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ILogger _metricsLogger = Log.ForContext().ForContext("MetricsData", true); + private readonly IReadOnlyList _providers; + private readonly MetricsConfig _config; + private readonly IEventBus? _eventBus; + private readonly Lock _syncRoot = new(); + private CancellationTokenSource _lifetimeCts = new(); + private Task _collectionTask = Task.CompletedTask; + private int _disposed; + private int _started; + + private MetricsSnapshot _snapshot = new( + DateTimeOffset.MinValue, + new Dictionary(StringComparer.Ordinal) + ); + + /// + /// Initializes the metrics collection service. + /// + /// Metric providers to collect from. + /// Metrics collection configuration. + /// Optional event bus used to publish metrics collection events. + public MetricsCollectionService(IEnumerable providers, MetricsConfig config, IEventBus? eventBus = null) + { + ArgumentNullException.ThrowIfNull(providers); + ArgumentNullException.ThrowIfNull(config); + + _providers = [.. providers]; + _config = config; + _eventBus = eventBus; + } + + /// + public IReadOnlyDictionary GetAllMetrics() + { + lock (_syncRoot) + { + return _snapshot.Metrics; + } + } + + /// + public MetricsSnapshot GetStatus() + => GetSnapshot(); + + /// + public MetricsSnapshot GetSnapshot() + { + lock (_syncRoot) + { + return _snapshot; + } + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (!_config.Enabled) + { + return ValueTask.CompletedTask; + } + + if (Interlocked.CompareExchange(ref _started, 1, 0) != 0) + { + return ValueTask.CompletedTask; + } + + if (_lifetimeCts.IsCancellationRequested) + { + _lifetimeCts.Dispose(); + _lifetimeCts = new(); + } + + _collectionTask = Task.Run(() => RunCollectionLoopAsync(_lifetimeCts.Token), _lifetimeCts.Token); + + return ValueTask.CompletedTask; + } + + /// + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (Interlocked.CompareExchange(ref _started, 0, 1) != 1) + { + return; + } + + await _lifetimeCts.CancelAsync(); + + try + { + await _collectionTask; + } + catch (OperationCanceledException) + { + } + } + + private async Task CollectOnceAsync(CancellationToken cancellationToken) + { + var values = new Dictionary(StringComparer.Ordinal); + var now = DateTimeOffset.UtcNow; + + for (var i = 0; i < _providers.Count; i++) + { + var provider = _providers[i]; + + try + { + var samples = await provider.CollectAsync(cancellationToken); + + for (var sampleIndex = 0; sampleIndex < samples.Count; sampleIndex++) + { + var sample = samples[sampleIndex]; + var key = CreateMetricKey(provider.ProviderName, sample.Name); + values[key] = sample with { Timestamp = sample.Timestamp ?? now }; + } + + LogProviderCollection(provider, samples.Count); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Metrics provider collection failed for {ProviderName}", provider.ProviderName); + } + } + + var snapshot = new MetricsSnapshot(now, values); + + lock (_syncRoot) + { + _snapshot = snapshot; + } + + await PublishMetricsCollectedAsync(snapshot, cancellationToken); + } + + private static string CreateMetricKey(string providerName, string metricName) + => string.IsNullOrWhiteSpace(providerName) ? metricName : + string.IsNullOrWhiteSpace(metricName) ? providerName : providerName + "." + metricName; + + private void LogProviderCollection(IMetricProvider provider, int metricCount) + { + if (!_config.LogEnabled || _config.LogLevel == LogLevelType.None) + { + return; + } + + _metricsLogger.Write( + _config.LogLevel.ToSerilogLogLevel(), + "Collected {MetricCount} metrics from provider {ProviderName}", + metricCount, + provider.ProviderName + ); + } + + private async Task PublishMetricsCollectedAsync(MetricsSnapshot snapshot, CancellationToken cancellationToken) + { + if (_eventBus is null) + { + return; + } + + var eventData = new MetricsCollectedEvent(snapshot); + + try + { + _eventBus.Publish(eventData); + } + catch (Exception ex) + { + _logger.Error(ex, "Metrics collected event dispatch failed for synchronous listeners"); + } + + try + { + await _eventBus.PublishAsync(eventData, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Metrics collected event dispatch failed for asynchronous listeners"); + } + } + + private async Task RunCollectionLoopAsync(CancellationToken cancellationToken) + { + await CollectOnceAsync(cancellationToken); + + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(Math.Max(1, _config.IntervalMilliseconds))); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await CollectOnceAsync(cancellationToken); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(MetricsCollectionService)); + } + } + + /// + /// Releases metrics collection resources. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (Volatile.Read(ref _started) != 0) + { + _lifetimeCts.Cancel(); + + try + { + _collectionTask.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + } + } + + _lifetimeCts.Dispose(); + } +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs index 8c06585c..2a665002 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs @@ -8,6 +8,7 @@ using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Types; using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Bootstrap; using SquidStd.Tests.Support; namespace SquidStd.Tests.Bootstrap; diff --git a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs index 2c86be72..bb72c066 100644 --- a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs +++ b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs @@ -8,11 +8,12 @@ public class LogLevelExtensionsTests { [Theory, InlineData(LogLevelType.Trace, LogEventLevel.Verbose), InlineData(LogLevelType.Debug, LogEventLevel.Debug), InlineData(LogLevelType.Information, LogEventLevel.Information), - InlineData(LogLevelType.Warning, LogEventLevel.Warning), InlineData(LogLevelType.Error, LogEventLevel.Error)] + InlineData(LogLevelType.Warning, LogEventLevel.Warning), InlineData(LogLevelType.Error, LogEventLevel.Error), + InlineData(LogLevelType.Critical, LogEventLevel.Fatal)] public void ToSerilogLogLevel_KnownLevels_MapsExpected(LogLevelType input, LogEventLevel expected) => Assert.Equal(expected, input.ToSerilogLogLevel()); - [Theory, InlineData(LogLevelType.None), InlineData(LogLevelType.Critical)] - public void ToSerilogLogLevel_UnmappedLevels_FallsBackToInformation(LogLevelType input) - => Assert.Equal(LogEventLevel.Information, input.ToSerilogLogLevel()); + [Fact] + public void ToSerilogLogLevel_UnmappedLevels_FallsBackToInformation() + => Assert.Equal(LogEventLevel.Information, ((LogLevelType)255).ToSerilogLogLevel()); } diff --git a/tests/SquidStd.Tests/Metrics/MetricsConfigTests.cs b/tests/SquidStd.Tests/Metrics/MetricsConfigTests.cs new file mode 100644 index 00000000..8a0814ad --- /dev/null +++ b/tests/SquidStd.Tests/Metrics/MetricsConfigTests.cs @@ -0,0 +1,17 @@ +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Tests.Metrics; + +public class MetricsConfigTests +{ + [Fact] + public void MetricsConfig_ImplementsConfigEntry() + { + IConfigEntry entry = new MetricsConfig(); + + Assert.Equal("metrics", entry.SectionName); + Assert.Equal(typeof(MetricsConfig), entry.ConfigType); + Assert.IsType(entry.CreateDefault()); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs new file mode 100644 index 00000000..a097c643 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs @@ -0,0 +1,238 @@ +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Services.Core; + +public class MetricsCollectionServiceTests +{ + [Fact] + public async Task StartAsync_CollectsMetricsFromRegisteredProviders() + { + using var service = new MetricsCollectionService( + [new CountingMetricProvider("jobs", "pending.total", 7)], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("jobs.pending.total")); + + var metrics = service.GetAllMetrics(); + var sample = metrics["jobs.pending.total"]; + + Assert.Equal("pending.total", sample.Name); + Assert.Equal(7, sample.Value); + Assert.NotNull(sample.Timestamp); + } + + [Fact] + public async Task StartAsync_WhenProviderThrows_ContinuesCollectingOtherProviders() + { + using var service = new MetricsCollectionService( + [ + new ThrowingMetricProvider("broken"), + new CountingMetricProvider("timer", "callbacks.total", 3) + ], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("timer.callbacks.total")); + + var metrics = service.GetAllMetrics(); + + Assert.Contains("timer.callbacks.total", metrics.Keys); + Assert.DoesNotContain("broken", metrics.Keys); + } + + [Fact] + public async Task StopAsync_StopsCollectionLoop() + { + var provider = new CountingMetricProvider("bus", "dispatch.total", 1); + using var service = new MetricsCollectionService( + [provider], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => provider.CollectionCount > 0); + await service.StopAsync(CancellationToken.None); + var countAfterStop = provider.CollectionCount; + + await Task.Delay(50); + + Assert.Equal(countAfterStop, provider.CollectionCount); + } + + [Fact] + public async Task StartAsync_WhenDisabled_DoesNotCollectMetrics() + { + var provider = new CountingMetricProvider("jobs", "pending.total", 7); + using var service = new MetricsCollectionService( + [provider], + new() + { + Enabled = false, + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await Task.Delay(30); + + Assert.Empty(service.GetAllMetrics()); + Assert.Equal(0, provider.CollectionCount); + } + + [Fact] + public void GetSnapshot_WhenNotStarted_ReturnsEmptySnapshot() + { + using var service = new MetricsCollectionService([], new()); + + var snapshot = service.GetSnapshot(); + + Assert.Equal(DateTimeOffset.MinValue, snapshot.CollectedAt); + Assert.Empty(snapshot.Metrics); + } + + [Fact] + public async Task GetStatus_ReturnsLatestMetricsSnapshot() + { + using var service = new MetricsCollectionService( + [new CountingMetricProvider("jobs", "completed.total", 11)], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + IMetricsCollectionService metrics = service; + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => metrics.GetStatus().Metrics.ContainsKey("jobs.completed.total")); + + var status = metrics.GetStatus(); + + Assert.NotEqual(DateTimeOffset.MinValue, status.CollectedAt); + Assert.Equal(11, status.Metrics["jobs.completed.total"].Value); + } + + [Fact] + public async Task StartAsync_PublishesMetricsCollectedEventThroughEventBus() + { + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + var syncListener = new MetricsCollectedSyncListener(); + var asyncListener = new MetricsCollectedAsyncListener(); + bus.RegisterListener(syncListener); + bus.RegisterAsyncListener(asyncListener); + using var service = new MetricsCollectionService( + [new CountingMetricProvider("events", "published.total", 5)], + new() + { + IntervalMilliseconds = 1000, + LogEnabled = false + }, + bus + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => syncListener.LastEvent is not null && asyncListener.LastEvent is not null); + await service.StopAsync(CancellationToken.None); + + Assert.NotNull(syncListener.LastEvent); + Assert.NotNull(asyncListener.LastEvent); + Assert.Same(syncListener.LastEvent, asyncListener.LastEvent); + Assert.Same(service.GetStatus(), syncListener.LastEvent.Snapshot); + Assert.Equal(5, syncListener.LastEvent.Snapshot.Metrics["events.published.total"].Value); + } + + private static async Task WaitUntilAsync(Func predicate) + { + var deadline = DateTime.UtcNow.AddSeconds(2); + + while (DateTime.UtcNow < deadline) + { + if (predicate()) + { + return; + } + + await Task.Delay(10); + } + + Assert.Fail("Condition was not met before timeout."); + } + + private sealed class CountingMetricProvider : IMetricProvider + { + private readonly string _metricName; + private readonly double _value; + private int _collectionCount; + + public CountingMetricProvider(string providerName, string metricName, double value) + { + ProviderName = providerName; + _metricName = metricName; + _value = value; + } + + public int CollectionCount => Volatile.Read(ref _collectionCount); + + public string ProviderName { get; } + + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _collectionCount); + + return ValueTask.FromResult>([new(_metricName, _value)]); + } + } + + private sealed class ThrowingMetricProvider : IMetricProvider + { + public ThrowingMetricProvider(string providerName) + { + ProviderName = providerName; + } + + public string ProviderName { get; } + + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Synthetic test failure."); + } + + private sealed class MetricsCollectedSyncListener : ISyncEventListener + { + public MetricsCollectedEvent? LastEvent { get; private set; } + + public void Handle(MetricsCollectedEvent eventData) + => LastEvent = eventData; + } + + private sealed class MetricsCollectedAsyncListener : IAsyncEventListener + { + public MetricsCollectedEvent? LastEvent { get; private set; } + + public Task HandleAsync(MetricsCollectedEvent eventData, CancellationToken cancellationToken) + { + LastEvent = eventData; + + return Task.CompletedTask; + } + } +} diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index 98a7df12..6abf835f 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -3,8 +3,10 @@ using SquidStd.Abstractions.Data.Internal.Services; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; +using SquidStd.Core.Data.Metrics; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Interfaces.Metrics; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Services; using SquidStd.Tests.Support; @@ -102,4 +104,44 @@ public void RegisterDefaultCoreConfigSections_RegistersLoggerMetadata() ); Assert.False(container.IsRegistered()); } + + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersMetricsMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains(entries, entry => entry.SectionName == "metrics" && entry.ConfigType == typeof(MetricsConfig)); + Assert.False(container.IsRegistered()); + } + + [Fact] + public void RegisterMetricsCollectionService_AddsLateServiceRegistrationData() + { + using var container = new Container(); + + container.RegisterMetricsCollectionService(); + + var entry = Assert.Single( + container.Resolve>(), + registration => registration.ServiceType == typeof(IMetricsCollectionService) + ); + + Assert.Equal(typeof(MetricsCollectionService), entry.ImplementationType); + Assert.Equal(1000, entry.Priority); + } + + [Fact] + public void RegisterCoreServices_RegistersMetricsCollectionService() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterCoreServices("app", temp.Path); + + Assert.True(container.IsRegistered()); + } } diff --git a/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs new file mode 100644 index 00000000..900c5a58 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs @@ -0,0 +1,24 @@ +using Serilog; +using SquidStd.Core.Types; +using SquidStd.Services.Core.Extensions.Logger; + +namespace SquidStd.Tests.Services.Core; + +public class SquidStdLogRollingIntervalExtensionsTests +{ + [Theory, InlineData(SquidStdLogRollingIntervalType.Infinite, RollingInterval.Infinite), + InlineData(SquidStdLogRollingIntervalType.Year, RollingInterval.Year), + InlineData(SquidStdLogRollingIntervalType.Month, RollingInterval.Month), + InlineData(SquidStdLogRollingIntervalType.Day, RollingInterval.Day), + InlineData(SquidStdLogRollingIntervalType.Hour, RollingInterval.Hour), + InlineData(SquidStdLogRollingIntervalType.Minute, RollingInterval.Minute)] + public void ToSerilogRollingInterval_KnownIntervals_MapExpected( + SquidStdLogRollingIntervalType input, + RollingInterval expected + ) + => Assert.Equal(expected, input.ToSerilogRollingInterval()); + + [Fact] + public void ToSerilogRollingInterval_UnmappedInterval_FallsBackToDay() + => Assert.Equal(RollingInterval.Day, ((SquidStdLogRollingIntervalType)255).ToSerilogRollingInterval()); +} From 1daf366144949c93159de4e80a6c5f1e779f6fb0 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:35:44 +0200 Subject: [PATCH 15/98] feat(storage): add YAML storage and encrypted secrets - feat: add storage and secret contracts with config sections - feat: add file storage, YAML object storage, and AES-GCM secret protection - feat: register storage and secret services in the core service extensions - test: cover storage roundtrips, secret encryption, fallback key warning, and DI registration --- .../Data/Storage/SecretsConfig.cs | 26 +++ .../Data/Storage/StorageConfig.cs | 21 ++ .../Interfaces/Secrets/ISecretProtector.cs | 21 ++ .../Interfaces/Secrets/ISecretStore.cs | 39 ++++ .../Storage/IObjectStorageService.cs | 41 ++++ .../Interfaces/Storage/IStorageService.cs | 39 ++++ .../RegisterDefaultServicesExtensions.cs | 34 ++++ .../Services/Internal/StoragePathResolver.cs | 53 +++++ .../Services/Storage/AesGcmSecretProtector.cs | 111 +++++++++++ .../Services/Storage/FileSecretStore.cs | 70 +++++++ .../Services/Storage/FileStorageService.cs | 94 +++++++++ .../Storage/YamlObjectStorageService.cs | 54 ++++++ .../RegisterDefaultServicesExtensionsTests.cs | 60 ++++++ .../Core/Storage/FileStorageServiceTests.cs | 181 ++++++++++++++++++ .../Storage/StorageConfigTests.cs | 27 +++ 15 files changed, 871 insertions(+) create mode 100644 src/SquidStd.Core/Data/Storage/SecretsConfig.cs create mode 100644 src/SquidStd.Core/Data/Storage/StorageConfig.cs create mode 100644 src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs create mode 100644 src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs create mode 100644 src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs create mode 100644 src/SquidStd.Core/Interfaces/Storage/IStorageService.cs create mode 100644 src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs create mode 100644 src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs create mode 100644 src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs create mode 100644 src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs create mode 100644 src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs create mode 100644 tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs create mode 100644 tests/SquidStd.Tests/Storage/StorageConfigTests.cs diff --git a/src/SquidStd.Core/Data/Storage/SecretsConfig.cs b/src/SquidStd.Core/Data/Storage/SecretsConfig.cs new file mode 100644 index 00000000..ee471b1a --- /dev/null +++ b/src/SquidStd.Core/Data/Storage/SecretsConfig.cs @@ -0,0 +1,26 @@ +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Core.Data.Storage; + +/// +/// Configuration for encrypted local secret storage. +/// +public sealed class SecretsConfig : IConfigEntry +{ + /// + /// Gets or sets the root directory used by local secret storage. + /// + public string RootDirectory { get; set; } = "secrets"; + + /// + /// Gets or sets the environment variable that contains the base64 AES key. + /// + public string KeyEnvironmentVariable { get; set; } = "SQUIDSTD_SECRETS_KEY"; + + string IConfigEntry.SectionName => "secrets"; + + Type IConfigEntry.ConfigType => typeof(SecretsConfig); + + object IConfigEntry.CreateDefault() + => new SecretsConfig(); +} diff --git a/src/SquidStd.Core/Data/Storage/StorageConfig.cs b/src/SquidStd.Core/Data/Storage/StorageConfig.cs new file mode 100644 index 00000000..c866ab3f --- /dev/null +++ b/src/SquidStd.Core/Data/Storage/StorageConfig.cs @@ -0,0 +1,21 @@ +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Core.Data.Storage; + +/// +/// Configuration for local file storage. +/// +public sealed class StorageConfig : IConfigEntry +{ + /// + /// Gets or sets the root directory used by local storage. + /// + public string RootDirectory { get; set; } = "storage"; + + string IConfigEntry.SectionName => "storage"; + + Type IConfigEntry.ConfigType => typeof(StorageConfig); + + object IConfigEntry.CreateDefault() + => new StorageConfig(); +} diff --git a/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs b/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs new file mode 100644 index 00000000..07fc86cb --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs @@ -0,0 +1,21 @@ +namespace SquidStd.Core.Interfaces.Secrets; + +/// +/// Protects and unprotects secret payloads. +/// +public interface ISecretProtector +{ + /// + /// Protects a plaintext payload. + /// + /// Plaintext bytes to protect. + /// Protected payload bytes. + byte[] Protect(byte[] plaintext); + + /// + /// Unprotects a protected payload. + /// + /// Protected payload bytes. + /// Plaintext bytes. + byte[] Unprotect(byte[] protectedData); +} diff --git a/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs new file mode 100644 index 00000000..a22b87a4 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs @@ -0,0 +1,39 @@ +namespace SquidStd.Core.Interfaces.Secrets; + +/// +/// Stores encrypted secret values by logical name. +/// +public interface ISecretStore +{ + /// + /// Deletes a secret. + /// + /// The secret name. + /// Token used to cancel the operation. + /// true when a secret was deleted; otherwise false. + ValueTask DeleteAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Checks whether a secret exists. + /// + /// The secret name. + /// Token used to cancel the operation. + /// true when the secret exists; otherwise false. + ValueTask ExistsAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Gets a secret value. + /// + /// The secret name. + /// Token used to cancel the operation. + /// The secret value, or null when it does not exist. + ValueTask GetAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Sets a secret value. + /// + /// The secret name. + /// The secret value. + /// Token used to cancel the operation. + ValueTask SetAsync(string name, string value, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs b/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs new file mode 100644 index 00000000..03647d4a --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs @@ -0,0 +1,41 @@ +namespace SquidStd.Core.Interfaces.Storage; + +/// +/// Stores typed objects by logical key. +/// +public interface IObjectStorageService +{ + /// + /// Deletes a stored object. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when a payload was deleted; otherwise false. + ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Checks whether an object exists. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when the object exists; otherwise false. + ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Loads a stored object. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// The object type. + /// The object, or null when it does not exist. + ValueTask LoadAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Saves an object. + /// + /// The logical storage key. + /// The object value. + /// Token used to cancel the operation. + /// The object type. + ValueTask SaveAsync(string key, T value, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs b/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs new file mode 100644 index 00000000..50b0771b --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs @@ -0,0 +1,39 @@ +namespace SquidStd.Core.Interfaces.Storage; + +/// +/// Stores binary payloads by logical key. +/// +public interface IStorageService +{ + /// + /// Deletes a stored payload. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when a payload was deleted; otherwise false. + ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Checks whether a payload exists. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when the payload exists; otherwise false. + ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Loads a binary payload. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// The payload, or null when it does not exist. + ValueTask LoadAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Saves a binary payload atomically. + /// + /// The logical storage key. + /// The payload to store. + /// Token used to cancel the operation. + ValueTask SaveAsync(string key, ReadOnlyMemory data, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index b3260253..23884f9c 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -6,14 +6,18 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Data.Storage; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Jobs; using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Core.Interfaces.Storage; using SquidStd.Core.Interfaces.Threading; using SquidStd.Core.Interfaces.Timing; using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Storage; namespace SquidStd.Services.Core.Extensions; @@ -65,6 +69,8 @@ public IContainer RegisterCoreServices(string configName, string configDirectory container.RegisterMainThreadDispatcherService(); container.RegisterTimerWheelService(); container.RegisterMetricsCollectionService(); + container.RegisterStorageServices(); + container.RegisterSecretServices(); return container; } @@ -79,6 +85,8 @@ public IContainer RegisterDefaultCoreConfigSections() container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); container.RegisterConfigSection("metrics", static () => new MetricsConfig(), -80); + container.RegisterConfigSection("storage", static () => new StorageConfig(), -70); + container.RegisterConfigSection("secrets", static () => new SecretsConfig(), -60); return container; } @@ -112,6 +120,32 @@ public IContainer RegisterMetricsCollectionService() return container.RegisterStdService(1000); } + /// + /// Registers default local storage services in the container. + /// + /// The same container for chaining. + public IContainer RegisterStorageServices() + { + container.RegisterConfigSection("storage", static () => new StorageConfig(), -70); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + + /// + /// Registers default encrypted local secret services in the container. + /// + /// The same container for chaining. + public IContainer RegisterSecretServices() + { + container.RegisterConfigSection("secrets", static () => new SecretsConfig(), -60); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + /// /// Registers the default main-thread dispatcher service in the container. /// diff --git a/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs b/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs new file mode 100644 index 00000000..048fcd98 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs @@ -0,0 +1,53 @@ +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// Resolves logical storage keys into paths constrained to one root directory. +/// +internal static class StoragePathResolver +{ + public static string ResolveFilePath(string rootDirectory, string key, string? extension = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (Path.IsPathRooted(key)) + { + throw new ArgumentException("Storage key cannot be rooted.", nameof(key)); + } + + var normalizedRoot = Path.GetFullPath(rootDirectory); + var segments = key.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length == 0) + { + throw new ArgumentException("Storage key cannot be empty.", nameof(key)); + } + + for (var i = 0; i < segments.Length; i++) + { + if (segments[i] is "." or "..") + { + throw new ArgumentException("Storage key cannot contain relative path segments.", nameof(key)); + } + } + + var relativePath = Path.Combine(segments); + + if (!string.IsNullOrWhiteSpace(extension)) + { + relativePath += extension; + } + + var fullPath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath)); + var rootPrefix = normalizedRoot.EndsWith(Path.DirectorySeparatorChar) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + + if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException("Storage key resolves outside the storage root.", nameof(key)); + } + + return fullPath; + } +} diff --git a/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs new file mode 100644 index 00000000..53de0334 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Text; +using Serilog; +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Secrets; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// Protects secrets using AES-GCM and a key supplied by environment variable. +/// +public sealed class AesGcmSecretProtector : ISecretProtector +{ + private const string EnvelopePrefix = "SQUIDSTD-AESGCM-V1"; + private const string DefaultKeyMaterial = "SquidStd.DefaultDevelopmentSecretsKey.v1"; + private const int NonceSize = 12; + private const int TagSize = 16; + private readonly byte[] _key; + + /// + /// Initializes the AES-GCM secret protector. + /// + /// Secret storage configuration. + public AesGcmSecretProtector(SecretsConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _key = ResolveKey(config.KeyEnvironmentVariable); + } + + /// + public byte[] Protect(byte[] plaintext) + { + ArgumentNullException.ThrowIfNull(plaintext); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(_key, TagSize); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + var envelope = string.Join( + ":", + EnvelopePrefix, + Convert.ToBase64String(nonce), + Convert.ToBase64String(tag), + Convert.ToBase64String(ciphertext) + ); + + return Encoding.UTF8.GetBytes(envelope); + } + + /// + public byte[] Unprotect(byte[] protectedData) + { + ArgumentNullException.ThrowIfNull(protectedData); + + var envelope = Encoding.UTF8.GetString(protectedData); + var parts = envelope.Split(':'); + + if (parts.Length != 4 || !string.Equals(parts[0], EnvelopePrefix, StringComparison.Ordinal)) + { + throw new CryptographicException("Unsupported secret payload format."); + } + + var nonce = Convert.FromBase64String(parts[1]); + var tag = Convert.FromBase64String(parts[2]); + var ciphertext = Convert.FromBase64String(parts[3]); + var plaintext = new byte[ciphertext.Length]; + + using var aes = new AesGcm(_key, TagSize); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + return plaintext; + } + + private static byte[] ResolveKey(string environmentVariable) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariable); + + var value = Environment.GetEnvironmentVariable(environmentVariable); + + if (string.IsNullOrWhiteSpace(value)) + { + Log.Warning( + "Secret key environment variable {EnvironmentVariable} is not set. Using the default development secret key.", + environmentVariable + ); + + return CreateDefaultKey(); + } + + byte[] key; + + try + { + key = Convert.FromBase64String(value); + } + catch (FormatException ex) + { + throw new InvalidOperationException("Secret key environment variable must contain a base64 value.", ex); + } + + return key.Length is 16 or 24 or 32 + ? key + : throw new InvalidOperationException("Secret key must be 16, 24, or 32 bytes after base64 decoding."); + } + + private static byte[] CreateDefaultKey() + => SHA256.HashData(Encoding.UTF8.GetBytes(DefaultKeyMaterial)); +} diff --git a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs new file mode 100644 index 00000000..17a441b7 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs @@ -0,0 +1,70 @@ +using System.Text; +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Core.Interfaces.Storage; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// File-backed encrypted secret store. +/// +public sealed class FileSecretStore : ISecretStore +{ + private readonly ISecretProtector _secretProtector; + private readonly IStorageService _storageService; + + /// + /// Initializes the encrypted file secret store. + /// + /// Secret storage configuration. + /// Secret protector used for encryption. + public FileSecretStore(SecretsConfig config, ISecretProtector secretProtector) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(secretProtector); + + _secretProtector = secretProtector; + _storageService = new FileStorageService(new() { RootDirectory = config.RootDirectory }); + } + + /// + public ValueTask DeleteAsync(string name, CancellationToken cancellationToken = default) + => _storageService.DeleteAsync(ToStorageKey(name), cancellationToken); + + /// + public ValueTask ExistsAsync(string name, CancellationToken cancellationToken = default) + => _storageService.ExistsAsync(ToStorageKey(name), cancellationToken); + + /// + public async ValueTask GetAsync(string name, CancellationToken cancellationToken = default) + { + var protectedData = await _storageService.LoadAsync(ToStorageKey(name), cancellationToken); + + if (protectedData is null) + { + return null; + } + + var plaintext = _secretProtector.Unprotect(protectedData); + + return Encoding.UTF8.GetString(plaintext); + } + + /// + public async ValueTask SetAsync(string name, string value, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(value); + + var plaintext = Encoding.UTF8.GetBytes(value); + var protectedData = _secretProtector.Protect(plaintext); + + await _storageService.SaveAsync(ToStorageKey(name), protectedData, cancellationToken); + } + + private static string ToStorageKey(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return name + ".secret"; + } +} diff --git a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs b/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs new file mode 100644 index 00000000..debadf4c --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs @@ -0,0 +1,94 @@ +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Storage; +using SquidStd.Services.Core.Services.Internal; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// Local file-backed binary storage. +/// +public sealed class FileStorageService : IStorageService +{ + private readonly string _rootDirectory; + + /// + /// Initializes local file storage. + /// + /// Storage configuration. + public FileStorageService(StorageConfig config) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrWhiteSpace(config.RootDirectory); + + _rootDirectory = Path.GetFullPath(config.RootDirectory); + } + + /// + public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = ResolvePath(key); + + if (!File.Exists(path)) + { + return ValueTask.FromResult(false); + } + + File.Delete(path); + + return ValueTask.FromResult(true); + } + + /// + public ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + return ValueTask.FromResult(File.Exists(ResolvePath(key))); + } + + /// + public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) + { + var path = ResolvePath(key); + + return !File.Exists(path) ? null : await File.ReadAllBytesAsync(path, cancellationToken); + } + + /// + public async ValueTask SaveAsync( + string key, + ReadOnlyMemory data, + CancellationToken cancellationToken = default + ) + { + var path = ResolvePath(key); + var directory = Path.GetDirectoryName(path); + + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var tempPath = Path.Combine( + directory ?? _rootDirectory, + "." + Path.GetFileName(path) + "." + Guid.NewGuid().ToString("N") + ".tmp" + ); + + try + { + await File.WriteAllBytesAsync(tempPath, data.ToArray(), cancellationToken); + File.Move(tempPath, path, true); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private string ResolvePath(string key) + => StoragePathResolver.ResolveFilePath(_rootDirectory, key); +} diff --git a/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs b/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs new file mode 100644 index 00000000..63ddb76a --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs @@ -0,0 +1,54 @@ +using System.Text; +using SquidStd.Core.Interfaces.Storage; +using SquidStd.Core.Yaml; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// YAML object storage built on top of binary storage. +/// +public sealed class YamlObjectStorageService : IObjectStorageService +{ + private readonly IStorageService _storageService; + + /// + /// Initializes YAML object storage. + /// + /// Underlying binary storage service. + public YamlObjectStorageService(IStorageService storageService) + { + _storageService = storageService; + } + + /// + public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) + => _storageService.DeleteAsync(key, cancellationToken); + + /// + public ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) + => _storageService.ExistsAsync(key, cancellationToken); + + /// + public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) + { + var data = await _storageService.LoadAsync(key, cancellationToken); + + if (data is null) + { + return default; + } + + var yaml = Encoding.UTF8.GetString(data); + + return YamlUtils.Deserialize(yaml); + } + + /// + public async ValueTask SaveAsync(string key, T value, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(value); + + var yaml = YamlUtils.Serialize(value); + await _storageService.SaveAsync(key, Encoding.UTF8.GetBytes(yaml), cancellationToken); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index 6abf835f..3f281b57 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -4,11 +4,15 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Data.Storage; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Core.Interfaces.Storage; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Storage; using SquidStd.Tests.Support; namespace SquidStd.Tests.Services.Core; @@ -118,6 +122,21 @@ public void RegisterDefaultCoreConfigSections_RegistersMetricsMetadata() Assert.False(container.IsRegistered()); } + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersStorageAndSecretsMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains(entries, entry => entry.SectionName == "storage" && entry.ConfigType == typeof(StorageConfig)); + Assert.Contains(entries, entry => entry.SectionName == "secrets" && entry.ConfigType == typeof(SecretsConfig)); + Assert.False(container.IsRegistered()); + Assert.False(container.IsRegistered()); + } + [Fact] public void RegisterMetricsCollectionService_AddsLateServiceRegistrationData() { @@ -144,4 +163,45 @@ public void RegisterCoreServices_RegistersMetricsCollectionService() Assert.True(container.IsRegistered()); } + + [Fact] + public void RegisterCoreServices_RegistersStorageAndSecretServices() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterCoreServices("app", temp.Path); + + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + } + + [Fact] + public async Task RegisterStorageServices_RegistersFileAndYamlStorage() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterStorageServices(); + container.RegisterConfigManagerService("app", temp.Path); + await ((ConfigManagerService)container.Resolve()).StartAsync(CancellationToken.None); + + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.IsType(container.Resolve()); + Assert.IsType(container.Resolve()); + } + + [Fact] + public void RegisterSecretServices_RegistersSecretProtectorAndStore() + { + using var container = new Container(); + + container.RegisterSecretServices(); + + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + } } diff --git a/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs new file mode 100644 index 00000000..bb998645 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs @@ -0,0 +1,181 @@ +using System.Security.Cryptography; +using System.Text; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using SquidStd.Core.Data.Storage; +using SquidStd.Services.Core.Services.Storage; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Services.Core.Storage; + +[Collection(SerilogEventSinkCollection.Name)] +public class FileStorageServiceTests +{ + [Fact] + public async Task SaveAsync_LoadAsync_RoundTripsBytes() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var data = Encoding.UTF8.GetBytes("hello storage"); + + await service.SaveAsync("profiles/main.bin", data); + + var loaded = await service.LoadAsync("profiles/main.bin"); + + Assert.Equal(data, loaded); + Assert.True(await service.ExistsAsync("profiles/main.bin")); + } + + [Fact] + public async Task DeleteAsync_RemovesStoredValue() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + await service.SaveAsync("cache/value.bin", new byte[] { 1, 2, 3 }); + + var deleted = await service.DeleteAsync("cache/value.bin"); + + Assert.True(deleted); + Assert.False(await service.ExistsAsync("cache/value.bin")); + Assert.Null(await service.LoadAsync("cache/value.bin")); + } + + [Theory, InlineData("../escape.bin"), InlineData("/absolute.bin"), InlineData("nested/../../escape.bin")] + public async Task SaveAsync_RejectsUnsafeKeys(string key) + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + + await Assert.ThrowsAsync(() => service.SaveAsync(key, new byte[] { 1 }).AsTask()); + } + + [Fact] + public async Task ObjectStorage_SaveAsync_LoadAsync_RoundTripsYamlObject() + { + using var temp = new TempDirectory(); + var storage = new FileStorageService(new() { RootDirectory = temp.Path }); + var objects = new YamlObjectStorageService(storage); + var expected = new SampleObject + { + Name = "main", + Value = 42 + }; + + await objects.SaveAsync("objects/sample.yaml", expected); + + var actual = await objects.LoadAsync("objects/sample.yaml"); + + Assert.NotNull(actual); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.Value, actual.Value); + } + + [Fact] + public void AesGcmSecretProtector_Protect_Unprotect_RoundTripsWithoutPlaintext() + { + var variableName = "SQUIDSTD_TEST_SECRET_KEY"; + var previous = Environment.GetEnvironmentVariable(variableName); + var key = RandomNumberGenerator.GetBytes(32); + + try + { + Environment.SetEnvironmentVariable(variableName, Convert.ToBase64String(key)); + var protector = new AesGcmSecretProtector(new() { KeyEnvironmentVariable = variableName }); + var plaintext = Encoding.UTF8.GetBytes("super-secret-value"); + + var protectedData = protector.Protect(plaintext); + var unprotected = protector.Unprotect(protectedData); + + Assert.Equal(plaintext, unprotected); + Assert.DoesNotContain("super-secret-value", Encoding.UTF8.GetString(protectedData)); + } + finally + { + Environment.SetEnvironmentVariable(variableName, previous); + } + } + + [Fact] + public void AesGcmSecretProtector_WhenKeyEnvironmentVariableIsMissing_UsesDefaultKeyAndLogsWarning() + { + var variableName = "SQUIDSTD_TEST_SECRET_KEY_MISSING"; + var previous = Environment.GetEnvironmentVariable(variableName); + var previousLogger = Log.Logger; + var sink = new CapturingSink(); + + try + { + Environment.SetEnvironmentVariable(variableName, null); + Log.Logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); + var protector = new AesGcmSecretProtector(new() { KeyEnvironmentVariable = variableName }); + var plaintext = Encoding.UTF8.GetBytes("default-key-secret"); + + var protectedData = protector.Protect(plaintext); + var unprotected = protector.Unprotect(protectedData); + + Assert.Equal(plaintext, unprotected); + Assert.Contains( + sink.Events, + logEvent => logEvent.Level == LogEventLevel.Warning && + logEvent.RenderMessage().Contains(variableName, StringComparison.Ordinal) + ); + } + finally + { + Log.Logger = previousLogger; + Environment.SetEnvironmentVariable(variableName, previous); + } + } + + [Fact] + public async Task FileSecretStore_SetAsync_GetAsync_StoresEncryptedPayload() + { + using var temp = new TempDirectory(); + var variableName = "SQUIDSTD_TEST_SECRET_STORE_KEY"; + var previous = Environment.GetEnvironmentVariable(variableName); + var key = RandomNumberGenerator.GetBytes(32); + + try + { + Environment.SetEnvironmentVariable(variableName, Convert.ToBase64String(key)); + var config = new SecretsConfig + { + RootDirectory = temp.Path, + KeyEnvironmentVariable = variableName + }; + var store = new FileSecretStore(config, new AesGcmSecretProtector(config)); + + await store.SetAsync("db/main-password", "super-secret-value"); + + var value = await store.GetAsync("db/main-password"); + var files = Directory.GetFiles(temp.Path, "*", SearchOption.AllDirectories); + var rawPayload = string.Join(Environment.NewLine, files.Select(File.ReadAllText)); + + Assert.Equal("super-secret-value", value); + Assert.True(await store.ExistsAsync("db/main-password")); + Assert.DoesNotContain("super-secret-value", rawPayload); + } + finally + { + Environment.SetEnvironmentVariable(variableName, previous); + } + } + + private sealed class SampleObject + { + public string Name { get; set; } = string.Empty; + + public int Value { get; set; } + } + + private sealed class CapturingSink : ILogEventSink + { + private readonly List _events = []; + + public IReadOnlyList Events => _events; + + public void Emit(LogEvent logEvent) + => _events.Add(logEvent); + } +} diff --git a/tests/SquidStd.Tests/Storage/StorageConfigTests.cs b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs new file mode 100644 index 00000000..78de2a4b --- /dev/null +++ b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Tests.Storage; + +public class StorageConfigTests +{ + [Fact] + public void StorageConfig_ImplementsConfigEntry() + { + IConfigEntry entry = new StorageConfig(); + + Assert.Equal("storage", entry.SectionName); + Assert.Equal(typeof(StorageConfig), entry.ConfigType); + Assert.IsType(entry.CreateDefault()); + } + + [Fact] + public void SecretsConfig_ImplementsConfigEntry() + { + IConfigEntry entry = new SecretsConfig(); + + Assert.Equal("secrets", entry.SectionName); + Assert.Equal(typeof(SecretsConfig), entry.ConfigType); + Assert.IsType(entry.CreateDefault()); + } +} From 11f6b8db51cdc3672ddd1748bc814830e88c30b1 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:36:48 +0200 Subject: [PATCH 16/98] refactor(bootstrap): simplify SquidStdBootstrap flow - refactor: compact forwarding constructors - refactor: simplify ConfigureServices container validation - refactor: compact cancellation handling in RunUntilShutdownAsync --- .../Services/Bootstrap/SquidStdBootstrap.cs | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index 028783c2..fc280489 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -34,18 +34,14 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap /// Initializes a bootstrapper with default options. /// public SquidStdBootstrap() - : this(new SquidStdOptions()) - { - } + : this(new SquidStdOptions()) { } /// /// Initializes a bootstrapper with the specified options. /// /// Bootstrap options used to register core services. public SquidStdBootstrap(SquidStdOptions options) - : this(options, new Container()) - { - } + : this(options, new Container()) { } private SquidStdBootstrap(SquidStdOptions options, IContainer container) { @@ -98,12 +94,9 @@ public ISquidStdBootstrap ConfigureServices(Func configu var configuredContainer = configure(Container); - if (!ReferenceEquals(configuredContainer, Container)) - { - throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); - } - - return this; + return !ReferenceEquals(configuredContainer, Container) + ? throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance.") + : this; } /// @@ -185,9 +178,7 @@ public async Task RunAsync(CancellationToken cancellationToken = default) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } finally { await StopAsync(CancellationToken.None); From 032985c916fa65e2aac09b922fbec1aba04b9aa9 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:38:30 +0200 Subject: [PATCH 17/98] ci: add Dependabot config and CodeQL code scanning workflow --- .github/dependabot.yml | 27 +++++++++++++++++++++++++ .github/workflows/codeql.yml | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b65933b7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + commit-message: + prefix: "build" + include: "scope" + groups: + dotnet: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + commit-message: + prefix: "ci" + include: "scope" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..e8bfbd5f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ +name: CodeQL + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: "27 3 * * 1" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (C#) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: csharp + build-mode: none + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:csharp" From 47f27e316f67489c8758e35785a52c0767174437 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:40:25 +0200 Subject: [PATCH 18/98] ci: run CodeQL on develop instead of main --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e8bfbd5f..054d3d76 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [ main ] + branches: [ develop ] pull_request: - branches: [ main ] + branches: [ develop ] schedule: - cron: "27 3 * * 1" workflow_dispatch: From 181d04a686ba50a2dd622514ab552ecc2a6f19ba Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:42:34 +0200 Subject: [PATCH 19/98] ci: bump codeql-action to v4 --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 054d3d76..74f78b97 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,13 +27,13 @@ jobs: uses: actions/checkout@v5 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: csharp build-mode: none queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:csharp" From a8370a86748da1b51a75d5aac8c5f00f55f3a5ad Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:48:28 +0200 Subject: [PATCH 20/98] refactor(bootstrap): use ILogger alias and async log flush on dispose --- .../Services/Bootstrap/SquidStdBootstrap.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index fc280489..a18ec9e0 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -217,7 +217,7 @@ private void ConfigureLogger() var logger = loggerConfiguration.CreateLogger(); Log.Logger = logger; - Container.RegisterInstance(logger, IfAlreadyRegistered.Replace); + Container.RegisterInstance(logger, IfAlreadyRegistered.Replace); _loggerConfigured = true; } @@ -320,7 +320,7 @@ public async ValueTask DisposeAsync() { if (_loggerConfigured) { - Log.CloseAndFlush(); + await Log.CloseAndFlushAsync(); } Container.Dispose(); From 78bfcf25eec111abdb2d9eaf5792dff6ee2d9ec4 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:51:11 +0200 Subject: [PATCH 21/98] feat(messaging): add messaging abstractions (contracts, options, no-op metrics) --- SquidStd.slnx | 3 ++ .../Interfaces/IMessageQueue.cs | 16 ++++++++ .../Interfaces/IMessageSerializer.cs | 13 ++++++ .../Interfaces/IMessagingMetrics.cs | 28 +++++++++++++ .../Interfaces/IQueueMessageListener.cs | 11 +++++ .../Interfaces/IQueueMessageListenerAsync.cs | 11 +++++ .../Interfaces/IQueueProvider.cs | 15 +++++++ .../MessagingOptions.cs | 16 ++++++++ .../NoOpMessagingMetrics.cs | 40 +++++++++++++++++++ .../SquidStd.Messaging.Abstractions.csproj | 14 +++++++ .../Messaging/MessagingOptionsTests.cs | 16 ++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 25 ++++++------ 12 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs create mode 100644 src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs create mode 100644 src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs create mode 100644 src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs create mode 100644 src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs create mode 100644 src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs create mode 100644 src/SquidStd.Messaging.Abstractions/MessagingOptions.cs create mode 100644 src/SquidStd.Messaging.Abstractions/NoOpMessagingMetrics.cs create mode 100644 src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj create mode 100644 tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index 28b5ceed..4ad08ba2 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -1,4 +1,7 @@ + + + diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs new file mode 100644 index 00000000..32f7011e --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Typed facade for publishing to and subscribing to named queues. +/// +public interface IMessageQueue +{ + /// Publishes a message to a named queue. + Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default); + + /// Subscribes a synchronous listener to a named queue. Dispose to unsubscribe. + IDisposable Subscribe(string queueName, IQueueMessageListener listener); + + /// Subscribes an asynchronous listener to a named queue. Dispose to unsubscribe. + IDisposable Subscribe(string queueName, IQueueMessageListenerAsync listener); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs new file mode 100644 index 00000000..7fec3dc9 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Serializes and deserializes queue message payloads. +/// +public interface IMessageSerializer +{ + /// Serializes a message to bytes. + ReadOnlyMemory Serialize(TMessage message); + + /// Deserializes bytes to a message. + TMessage Deserialize(ReadOnlyMemory payload); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs new file mode 100644 index 00000000..f3058c03 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Sink for messaging metric events. Implementations must be thread-safe. +/// +public interface IMessagingMetrics +{ + /// Records a message published to a queue. + void OnPublished(string queueName); + + /// Records a message delivered successfully. + void OnDelivered(string queueName); + + /// Records a failed delivery attempt. + void OnFailed(string queueName); + + /// Records a retry of a message. + void OnRetried(string queueName); + + /// Records a message moved to the dead-letter queue. + void OnDeadLettered(string queueName); + + /// Sets the current buffered depth of a queue. + void SetQueueDepth(string queueName, int depth); + + /// Sets the current subscriber count of a queue. + void SetSubscriberCount(string queueName, int count); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs new file mode 100644 index 00000000..b384b5f1 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Handles a queue message synchronously. +/// +/// The message payload type. +public interface IQueueMessageListener +{ + /// Handles a delivered message. + void Handle(TMessage message); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs new file mode 100644 index 00000000..e7708457 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Handles a queue message asynchronously. +/// +/// The message payload type. +public interface IQueueMessageListenerAsync +{ + /// Handles a delivered message. + Task HandleAsync(TMessage message, CancellationToken cancellationToken); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs new file mode 100644 index 00000000..18e26062 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs @@ -0,0 +1,15 @@ +using SquidStd.Abstractions.Interfaces.Services; + +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Byte-level queue transport owning buffering, round-robin delivery, retry and dead-lettering. +/// +public interface IQueueProvider : ISquidStdService, IAsyncDisposable +{ + /// Publishes a raw payload to a named queue. + Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default); + + /// Subscribes a raw handler to a named queue. Dispose to unsubscribe. + IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler); +} diff --git a/src/SquidStd.Messaging.Abstractions/MessagingOptions.cs b/src/SquidStd.Messaging.Abstractions/MessagingOptions.cs new file mode 100644 index 00000000..390bbc5f --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/MessagingOptions.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Messaging.Abstractions; + +/// +/// Configuration for the messaging system. +/// +public sealed class MessagingOptions +{ + /// Maximum delivery attempts before dead-lettering. Default 3. + public int MaxDeliveryAttempts { get; init; } = 3; + + /// Suffix appended to a queue name to form its dead-letter queue. Default ".dlq". + public string DeadLetterQueueSuffix { get; init; } = ".dlq"; + + /// Delay applied before re-enqueueing a failed message. Default zero. + public TimeSpan RetryDelay { get; init; } = TimeSpan.Zero; +} diff --git a/src/SquidStd.Messaging.Abstractions/NoOpMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/NoOpMessagingMetrics.cs new file mode 100644 index 00000000..c036c9f0 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/NoOpMessagingMetrics.cs @@ -0,0 +1,40 @@ +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging.Abstractions; + +/// +/// Metrics sink that ignores all events. Used when no metrics are configured. +/// +public sealed class NoOpMessagingMetrics : IMessagingMetrics +{ + /// Shared instance. + public static NoOpMessagingMetrics Instance { get; } = new(); + + public void OnPublished(string queueName) + { + } + + public void OnDelivered(string queueName) + { + } + + public void OnFailed(string queueName) + { + } + + public void OnRetried(string queueName) + { + } + + public void OnDeadLettered(string queueName) + { + } + + public void SetQueueDepth(string queueName, int depth) + { + } + + public void SetSubscriberCount(string queueName, int count) + { + } +} diff --git a/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj new file mode 100644 index 00000000..e7e82ace --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs new file mode 100644 index 00000000..378fc9c0 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs @@ -0,0 +1,16 @@ +using SquidStd.Messaging.Abstractions; + +namespace SquidStd.Tests.Messaging; + +public class MessagingOptionsTests +{ + [Fact] + public void Defaults_AreApplied() + { + var options = new MessagingOptions(); + + Assert.Equal(3, options.MaxDeliveryAttempts); + Assert.Equal(".dlq", options.DeadLetterQueueSuffix); + Assert.Equal(TimeSpan.Zero, options.RetryDelay); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index a2a941d8..892afa48 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -8,27 +8,28 @@ - - - - + + + + - + - + - - - - - - + + + + + + + From 89c0cad047901da598985048760ecf61f1862644 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:52:08 +0200 Subject: [PATCH 22/98] feat(messaging): add SquidStd.Messaging project with JSON serializer --- SquidStd.slnx | 1 + .../JsonMessageSerializer.cs | 29 +++++++++++++++++++ .../SquidStd.Messaging.csproj | 22 ++++++++++++++ .../Messaging/JsonMessageSerializerTests.cs | 26 +++++++++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 5 files changed, 79 insertions(+) create mode 100644 src/SquidStd.Messaging/JsonMessageSerializer.cs create mode 100644 src/SquidStd.Messaging/SquidStd.Messaging.csproj create mode 100644 tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index 4ad08ba2..9d412942 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -1,6 +1,7 @@ + diff --git a/src/SquidStd.Messaging/JsonMessageSerializer.cs b/src/SquidStd.Messaging/JsonMessageSerializer.cs new file mode 100644 index 00000000..19a5bd36 --- /dev/null +++ b/src/SquidStd.Messaging/JsonMessageSerializer.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging; + +/// +/// Default JSON message serializer based on System.Text.Json. +/// +public sealed class JsonMessageSerializer : IMessageSerializer +{ + private readonly JsonSerializerOptions _options; + + public JsonMessageSerializer() + { + _options = new(JsonSerializerDefaults.Web); + } + + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON serialization may require runtime code generation.")] + public ReadOnlyMemory Serialize(TMessage message) + => JsonSerializer.SerializeToUtf8Bytes(message, _options); + + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON deserialization may require runtime code generation.")] + public TMessage Deserialize(ReadOnlyMemory payload) + => JsonSerializer.Deserialize(payload.Span, _options) ?? + throw new InvalidOperationException($"Deserialization returned null for type {typeof(TMessage).Name}."); +} diff --git a/src/SquidStd.Messaging/SquidStd.Messaging.csproj b/src/SquidStd.Messaging/SquidStd.Messaging.csproj new file mode 100644 index 00000000..b7117b29 --- /dev/null +++ b/src/SquidStd.Messaging/SquidStd.Messaging.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs b/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs new file mode 100644 index 00000000..b8458155 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs @@ -0,0 +1,26 @@ +using SquidStd.Messaging; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Tests.Messaging; + +public class JsonMessageSerializerTests +{ + private sealed class Sample + { + public string Name { get; set; } = ""; + public int Count { get; set; } + } + + [Fact] + public void SerializeDeserialize_RoundTrips() + { + IMessageSerializer serializer = new JsonMessageSerializer(); + var original = new Sample { Name = "squid", Count = 7 }; + + var bytes = serializer.Serialize(original); + var restored = serializer.Deserialize(bytes); + + Assert.Equal("squid", restored.Name); + Assert.Equal(7, restored.Count); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 892afa48..f6f29154 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -30,6 +30,7 @@ + From 4e8ddea49e0a59e0f65d51317ea67bdbd884ac9d Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:52:55 +0200 Subject: [PATCH 23/98] feat(messaging): add MessagingMetricsProvider bridging IMessagingMetrics to IMetricProvider --- .../MessagingMetricsProvider.cs | 81 +++++++++++++++++++ .../MessagingMetricsProviderTests.cs | 38 +++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/SquidStd.Messaging/MessagingMetricsProvider.cs create mode 100644 tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs diff --git a/src/SquidStd.Messaging/MessagingMetricsProvider.cs b/src/SquidStd.Messaging/MessagingMetricsProvider.cs new file mode 100644 index 00000000..25a3d9d5 --- /dev/null +++ b/src/SquidStd.Messaging/MessagingMetricsProvider.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Types.Metrics; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging; + +/// +/// Accumulates messaging metrics and exposes them to the metrics collection system. +/// +public sealed class MessagingMetricsProvider : IMessagingMetrics, IMetricProvider +{ + private readonly ConcurrentDictionary _queues = new(StringComparer.Ordinal); + + /// + public string ProviderName => "messaging"; + + /// + public void OnPublished(string queueName) + => Interlocked.Increment(ref Counters(queueName).Published); + + /// + public void OnDelivered(string queueName) + => Interlocked.Increment(ref Counters(queueName).Delivered); + + /// + public void OnFailed(string queueName) + => Interlocked.Increment(ref Counters(queueName).Failed); + + /// + public void OnRetried(string queueName) + => Interlocked.Increment(ref Counters(queueName).Retried); + + /// + public void OnDeadLettered(string queueName) + => Interlocked.Increment(ref Counters(queueName).DeadLettered); + + /// + public void SetQueueDepth(string queueName, int depth) + => Volatile.Write(ref Counters(queueName).Depth, depth); + + /// + public void SetSubscriberCount(string queueName, int count) + => Volatile.Write(ref Counters(queueName).Subscribers, count); + + /// + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + var samples = new List(); + + foreach (var (queueName, counters) in _queues) + { + var tags = new Dictionary(StringComparer.Ordinal) { ["queue"] = queueName }; + + samples.Add(new("published", Interlocked.Read(ref counters.Published), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("delivered", Interlocked.Read(ref counters.Delivered), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("failed", Interlocked.Read(ref counters.Failed), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("retried", Interlocked.Read(ref counters.Retried), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("dead_lettered", Interlocked.Read(ref counters.DeadLettered), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("queue_depth", Volatile.Read(ref counters.Depth), Tags: tags)); + samples.Add(new("subscribers", Volatile.Read(ref counters.Subscribers), Tags: tags)); + } + + return ValueTask.FromResult>(samples); + } + + private QueueCounters Counters(string queueName) + => _queues.GetOrAdd(queueName, static _ => new QueueCounters()); + + private sealed class QueueCounters + { + public long Published; + public long Delivered; + public long Failed; + public long Retried; + public long DeadLettered; + public int Depth; + public int Subscribers; + } +} diff --git a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs new file mode 100644 index 00000000..dbe4a10c --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs @@ -0,0 +1,38 @@ +using SquidStd.Messaging; + +namespace SquidStd.Tests.Messaging; + +public class MessagingMetricsProviderTests +{ + [Fact] + public void ProviderName_IsMessaging() + => Assert.Equal("messaging", new MessagingMetricsProvider().ProviderName); + + [Fact] + public async Task CollectAsync_ReportsCountersAndGaugesPerQueue() + { + var metrics = new MessagingMetricsProvider(); + + metrics.OnPublished("orders"); + metrics.OnPublished("orders"); + metrics.OnDelivered("orders"); + metrics.OnFailed("orders"); + metrics.OnRetried("orders"); + metrics.OnDeadLettered("orders"); + metrics.SetQueueDepth("orders", 5); + metrics.SetSubscriberCount("orders", 2); + + var samples = await metrics.CollectAsync(); + + double Value(string name) => samples.Single( + s => s.Name == name && s.Tags != null && s.Tags["queue"] == "orders").Value; + + Assert.Equal(2, Value("published")); + Assert.Equal(1, Value("delivered")); + Assert.Equal(1, Value("failed")); + Assert.Equal(1, Value("retried")); + Assert.Equal(1, Value("dead_lettered")); + Assert.Equal(5, Value("queue_depth")); + Assert.Equal(2, Value("subscribers")); + } +} From 583fa268a3c9ff50758c3853d834cbafbb823e91 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:56:15 +0200 Subject: [PATCH 24/98] feat(messaging): add in-memory queue provider with round-robin, retry and dead-letter --- .../InMemoryQueueProvider.cs | 254 ++++++++++++++++++ .../Internal/InMemoryQueue.cs | 72 +++++ .../Internal/QueuedMessage.cs | 8 + .../Messaging/InMemoryQueueProviderTests.cs | 120 +++++++++ 4 files changed, 454 insertions(+) create mode 100644 src/SquidStd.Messaging/InMemoryQueueProvider.cs create mode 100644 src/SquidStd.Messaging/Internal/InMemoryQueue.cs create mode 100644 src/SquidStd.Messaging/Internal/QueuedMessage.cs create mode 100644 tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs diff --git a/src/SquidStd.Messaging/InMemoryQueueProvider.cs b/src/SquidStd.Messaging/InMemoryQueueProvider.cs new file mode 100644 index 00000000..e8eeba32 --- /dev/null +++ b/src/SquidStd.Messaging/InMemoryQueueProvider.cs @@ -0,0 +1,254 @@ +using System.Collections.Concurrent; +using Serilog; +using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Internal; + +namespace SquidStd.Messaging; + +/// +/// In-memory : one buffered channel + consumer loop per named queue, +/// round-robin delivery, retry and dead-lettering. +/// +public sealed class InMemoryQueueProvider : IQueueProvider +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ConcurrentDictionary _queues = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _shutdownCts = new(); + private readonly MessagingOptions _options; + private readonly IMessagingMetrics _metrics; + private readonly TimeProvider _timeProvider; + private int _disposed; + + public InMemoryQueueProvider(MessagingOptions options, IMessagingMetrics? metrics = null, TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options; + _metrics = metrics ?? NoOpMessagingMetrics.Instance; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + + /// + public Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + cancellationToken.ThrowIfCancellationRequested(); + + Enqueue(queueName, new(payload, 0)); + _metrics.OnPublished(queueName); + + return Task.CompletedTask; + } + + /// + public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + ArgumentNullException.ThrowIfNull(handler); + + var queue = GetOrCreate(queueName); + queue.AddHandler(handler); + _metrics.SetSubscriberCount(queueName, queue.HandlerCount); + + return new Subscription(this, queueName, queue, handler); + } + + private InMemoryQueue GetOrCreate(string queueName) + => _queues.GetOrAdd( + queueName, + name => + { + var queue = new InMemoryQueue(); + queue.ConsumerLoop = Task.Run(() => ConsumeLoopAsync(name, queue, _shutdownCts.Token), CancellationToken.None); + + return queue; + } + ); + + private void Enqueue(string queueName, QueuedMessage message) + => Write(GetOrCreate(queueName), queueName, message); + + private void Write(InMemoryQueue queue, string queueName, QueuedMessage message) + { + queue.Channel.Writer.TryWrite(message); + _metrics.SetQueueDepth(queueName, queue.IncrementDepth()); + } + + private async Task ConsumeLoopAsync(string queueName, InMemoryQueue queue, CancellationToken cancellationToken) + { + try + { + await foreach (var message in queue.Channel.Reader.ReadAllAsync(cancellationToken)) + { + _metrics.SetQueueDepth(queueName, queue.DecrementDepth()); + + var handler = await WaitForHandlerAsync(queue, cancellationToken); + + if (handler is null) + { + return; // shutting down + } + + try + { + await handler(message.Payload, cancellationToken); + _metrics.OnDelivered(queueName); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + HandleFailure(queueName, queue, message, ex); + } + } + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + catch (Exception ex) + { + _logger.Error(ex, "In-memory queue consumer loop failed for {QueueName}", queueName); + } + } + + private async Task, CancellationToken, Task>?> WaitForHandlerAsync( + InMemoryQueue queue, + CancellationToken cancellationToken + ) + { + // Messages can arrive before any subscriber; wait until a handler exists. + while (!cancellationToken.IsCancellationRequested) + { + var handler = queue.NextHandler(); + + if (handler is not null) + { + return handler; + } + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(10), _timeProvider, cancellationToken); + } + catch (OperationCanceledException) + { + return null; + } + } + + return null; + } + + private void HandleFailure(string queueName, InMemoryQueue queue, QueuedMessage message, Exception exception) + { + _metrics.OnFailed(queueName); + var nextAttempt = message.Attempt + 1; + + if (nextAttempt < _options.MaxDeliveryAttempts) + { + _metrics.OnRetried(queueName); + _ = RequeueAsync(queue, queueName, message with { Attempt = nextAttempt }); + + return; + } + + _logger.Warning(exception, "Message dead-lettered on queue {QueueName} after {Attempts} attempts", queueName, nextAttempt); + _metrics.OnDeadLettered(queueName); + Enqueue(queueName + _options.DeadLetterQueueSuffix, new(message.Payload, 0)); + } + + private async Task RequeueAsync(InMemoryQueue queue, string queueName, QueuedMessage message) + { + if (_options.RetryDelay > TimeSpan.Zero) + { + try + { + await Task.Delay(_options.RetryDelay, _timeProvider, _shutdownCts.Token); + } + catch (OperationCanceledException) + { + return; + } + } + + Write(queue, queueName, message); + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + await _shutdownCts.CancelAsync(); + + foreach (var queue in _queues.Values) + { + queue.Channel.Writer.TryComplete(); + } + + foreach (var queue in _queues.Values) + { + if (queue.ConsumerLoop is not null) + { + try + { + await queue.ConsumerLoop; + } + catch + { + // Loop failures are already logged. + } + } + } + + _shutdownCts.Dispose(); + } + + private sealed class Subscription : IDisposable + { + private readonly InMemoryQueueProvider _provider; + private readonly string _queueName; + private readonly InMemoryQueue _queue; + private readonly Func, CancellationToken, Task> _handler; + private int _disposed; + + public Subscription( + InMemoryQueueProvider provider, + string queueName, + InMemoryQueue queue, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _queueName = queueName; + _queue = queue; + _handler = handler; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _queue.RemoveHandler(_handler); + _provider._metrics.SetSubscriberCount(_queueName, _queue.HandlerCount); + } + } +} diff --git a/src/SquidStd.Messaging/Internal/InMemoryQueue.cs b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs new file mode 100644 index 00000000..8ae6a7c3 --- /dev/null +++ b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs @@ -0,0 +1,72 @@ +using System.Threading.Channels; + +namespace SquidStd.Messaging.Internal; + +/// +/// Per-queue in-memory state: the buffer channel, the registered handlers, and the round-robin index. +/// +internal sealed class InMemoryQueue +{ + private readonly Lock _handlerSync = new(); + private readonly List, CancellationToken, Task>> _handlers = []; + private int _roundRobinIndex; + private int _depth; + + public Channel Channel { get; } = System.Threading.Channels.Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); + + public Task? ConsumerLoop { get; set; } + + /// Increments the buffered depth and returns the new value. + public int IncrementDepth() + => Interlocked.Increment(ref _depth); + + /// Decrements the buffered depth and returns the new value. + public int DecrementDepth() + => Interlocked.Decrement(ref _depth); + + public int HandlerCount + { + get + { + lock (_handlerSync) + { + return _handlers.Count; + } + } + } + + public void AddHandler(Func, CancellationToken, Task> handler) + { + lock (_handlerSync) + { + _handlers.Add(handler); + } + } + + public void RemoveHandler(Func, CancellationToken, Task> handler) + { + lock (_handlerSync) + { + _handlers.Remove(handler); + } + } + + /// Returns the next handler in round-robin order, or null when none are registered. + public Func, CancellationToken, Task>? NextHandler() + { + lock (_handlerSync) + { + if (_handlers.Count == 0) + { + return null; + } + + var handler = _handlers[_roundRobinIndex % _handlers.Count]; + _roundRobinIndex = (_roundRobinIndex + 1) % _handlers.Count; + + return handler; + } + } +} diff --git a/src/SquidStd.Messaging/Internal/QueuedMessage.cs b/src/SquidStd.Messaging/Internal/QueuedMessage.cs new file mode 100644 index 00000000..0ff2c295 --- /dev/null +++ b/src/SquidStd.Messaging/Internal/QueuedMessage.cs @@ -0,0 +1,8 @@ +namespace SquidStd.Messaging.Internal; + +/// +/// A buffered queue message with its delivery attempt count. +/// +/// The raw message payload. +/// Number of delivery attempts already made (0 on first enqueue). +internal sealed record QueuedMessage(ReadOnlyMemory Payload, int Attempt); diff --git a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs new file mode 100644 index 00000000..c0ae71bb --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs @@ -0,0 +1,120 @@ +using System.Text; +using SquidStd.Messaging; +using SquidStd.Messaging.Abstractions; + +namespace SquidStd.Tests.Messaging; + +public class InMemoryQueueProviderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static InMemoryQueueProvider NewProvider(MessagingMetricsProvider? metrics = null, MessagingOptions? options = null) + => new(options ?? new MessagingOptions(), metrics ?? new MessagingMetricsProvider()); + + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); + + private static string Text(ReadOnlyMemory b) + => Encoding.UTF8.GetString(b.Span); + + [Fact] + public async Task Publish_DeliversToSubscriber() + { + await using var provider = NewProvider(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe("q", (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync("q", Bytes("hello")); + + Assert.Equal("hello", await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task MessagesPublishedBeforeSubscribe_AreBuffered() + { + await using var provider = NewProvider(); + await provider.PublishAsync("q", Bytes("early")); + + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe("q", (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + Assert.Equal("early", await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task TwoSubscribers_ReceiveRoundRobin() + { + await using var provider = NewProvider(); + var aCount = 0; + var bCount = 0; + var done = new CountdownEvent(4); + provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref aCount); done.Signal(); return Task.CompletedTask; }); + provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref bCount); done.Signal(); return Task.CompletedTask; }); + + for (var i = 0; i < 4; i++) + { + await provider.PublishAsync("q", Bytes("m")); + } + + Assert.True(done.Wait(Timeout)); + Assert.Equal(2, aCount); + Assert.Equal(2, bCount); + } + + [Fact] + public async Task FailingThenSucceeding_IsRetriedAndDelivered() + { + await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 3 }); + var attempts = 0; + var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe( + "q", + (_, _) => + { + if (Interlocked.Increment(ref attempts) < 2) + { + throw new InvalidOperationException("transient"); + } + + delivered.TrySetResult(); + + return Task.CompletedTask; + } + ); + + await provider.PublishAsync("q", Bytes("m")); + + await delivered.Task.WaitAsync(Timeout); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task AlwaysFailing_IsDeadLetteredAfterMaxAttempts() + { + await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 2 }); + var attempts = 0; + provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref attempts); throw new InvalidOperationException("always"); }); + + var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe("q.dlq", (payload, _) => { deadLettered.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync("q", Bytes("poison")); + + Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task DisposedSubscription_StopsReceiving() + { + await using var provider = NewProvider(); + var count = 0; + var subscription = provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref count); return Task.CompletedTask; }); + subscription.Dispose(); + + await provider.PublishAsync("q", Bytes("m")); + await Task.Delay(200); + + Assert.Equal(0, count); + } +} From 179bce3703a8954a1f5298356976dee6daa9ffd7 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:56:58 +0200 Subject: [PATCH 25/98] feat(messaging): add typed MessageQueue facade over the byte-level provider --- src/SquidStd.Messaging/MessageQueue.cs | 50 +++++++++++++++++++ .../Messaging/MessageQueueTests.cs | 43 ++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/SquidStd.Messaging/MessageQueue.cs create mode 100644 tests/SquidStd.Tests/Messaging/MessageQueueTests.cs diff --git a/src/SquidStd.Messaging/MessageQueue.cs b/src/SquidStd.Messaging/MessageQueue.cs new file mode 100644 index 00000000..0c2c30ca --- /dev/null +++ b/src/SquidStd.Messaging/MessageQueue.cs @@ -0,0 +1,50 @@ +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging; + +/// +/// Typed facade over an : serializes outgoing messages and +/// deserializes incoming payloads before handing them to typed listeners. +/// +public sealed class MessageQueue : IMessageQueue +{ + private readonly IQueueProvider _provider; + private readonly IMessageSerializer _serializer; + + public MessageQueue(IQueueProvider provider, IMessageSerializer serializer) + { + _provider = provider; + _serializer = serializer; + } + + /// + public Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default) + => _provider.PublishAsync(queueName, _serializer.Serialize(message), cancellationToken); + + /// + public IDisposable Subscribe(string queueName, IQueueMessageListener listener) + { + ArgumentNullException.ThrowIfNull(listener); + + return _provider.Subscribe( + queueName, + (payload, _) => + { + listener.Handle(_serializer.Deserialize(payload)); + + return Task.CompletedTask; + } + ); + } + + /// + public IDisposable Subscribe(string queueName, IQueueMessageListenerAsync listener) + { + ArgumentNullException.ThrowIfNull(listener); + + return _provider.Subscribe( + queueName, + (payload, cancellationToken) => listener.HandleAsync(_serializer.Deserialize(payload), cancellationToken) + ); + } +} diff --git a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs new file mode 100644 index 00000000..2b05209b --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs @@ -0,0 +1,43 @@ +using SquidStd.Messaging; +using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Tests.Messaging; + +public class MessageQueueTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private sealed class Order + { + public string Id { get; set; } = ""; + public int Amount { get; set; } + } + + private sealed class CapturingListener : IQueueMessageListenerAsync + { + public TaskCompletionSource Received { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task HandleAsync(Order message, CancellationToken cancellationToken) + { + Received.TrySetResult(message); + + return Task.CompletedTask; + } + } + + [Fact] + public async Task PublishAsync_DeliversTypedMessageToListener() + { + await using var provider = new InMemoryQueueProvider(new MessagingOptions(), new MessagingMetricsProvider()); + IMessageQueue queue = new MessageQueue(provider, new JsonMessageSerializer()); + var listener = new CapturingListener(); + queue.Subscribe("orders", listener); + + await queue.PublishAsync("orders", new Order { Id = "A1", Amount = 42 }); + + var received = await listener.Received.Task.WaitAsync(Timeout); + Assert.Equal("A1", received.Id); + Assert.Equal(42, received.Amount); + } +} From 3ce93338d8e1fb95c20f96fa0c85aae39daf26f8 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:57:07 +0200 Subject: [PATCH 26/98] feat(bootstrap): support external DryIoc containers --- .../Services/Bootstrap/SquidStdBootstrap.cs | 26 +++++++++-- .../Bootstrap/SquidStdBootstrapTests.cs | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index a18ec9e0..e870864a 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -20,6 +20,7 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap { private readonly Lock _syncRoot = new(); private readonly List _startedServices = []; + private readonly bool _ownsContainer; private int _disposed; private bool _loggerConfigured; private BootstrapStateType _state; @@ -34,16 +35,20 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap /// Initializes a bootstrapper with default options. /// public SquidStdBootstrap() - : this(new SquidStdOptions()) { } + : this(new SquidStdOptions()) + { + } /// /// Initializes a bootstrapper with the specified options. /// /// Bootstrap options used to register core services. public SquidStdBootstrap(SquidStdOptions options) - : this(options, new Container()) { } + : this(options, new Container(), true) + { + } - private SquidStdBootstrap(SquidStdOptions options, IContainer container) + private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ownsContainer) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(container); @@ -52,6 +57,7 @@ private SquidStdBootstrap(SquidStdOptions options, IContainer container) Options = options; Container = container; + _ownsContainer = ownsContainer; Container.RegisterInstance(this, IfAlreadyRegistered.Replace); Container.RegisterInstance(this, IfAlreadyRegistered.Replace); @@ -74,6 +80,15 @@ public static SquidStdBootstrap Create() public static SquidStdBootstrap Create(SquidStdOptions options) => new(options); + /// + /// Creates a bootstrapper using an externally owned DryIoc container. + /// + /// Bootstrap options used to register core services. + /// Externally owned container that receives SquidStd services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdOptions options, IContainer container) + => new(options, container, false); + /// public ISquidStdBootstrap ConfigureService(Func configure) => ConfigureServices(configure); @@ -323,7 +338,10 @@ public async ValueTask DisposeAsync() await Log.CloseAndFlushAsync(); } - Container.Dispose(); + if (_ownsContainer) + { + Container.Dispose(); + } } } } diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs index 2a665002..ad32af8d 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs @@ -32,6 +32,52 @@ public async Task Create_RegistersBootstrapAndDefaultServices() Assert.Equal(Path.Combine(temp.Path, "app.yaml"), configManager.ConfigPath); } + [Fact] + public async Task Create_WithContainer_UsesProvidedContainer() + { + using var temp = new TempDirectory(); + var container = new Container(); + + await using var bootstrap = SquidStdBootstrap.Create( + new() + { + ConfigName = "app", + RootDirectory = temp.Path + }, + container + ); + + Assert.Same(container, bootstrap.Container); + Assert.Same(bootstrap, container.Resolve()); + Assert.Same(bootstrap.Options, container.Resolve()); + + container.Dispose(); + } + + [Fact] + public async Task DisposeAsync_WithProvidedContainer_DoesNotDisposeContainer() + { + using var temp = new TempDirectory(); + var container = new Container(); + + await using (SquidStdBootstrap.Create( + new() + { + ConfigName = "app", + RootDirectory = temp.Path + }, + container + )) + { + } + + container.RegisterInstance("still-open"); + + Assert.Equal("still-open", container.Resolve()); + + container.Dispose(); + } + [Fact] public async Task StartAsync_LoadsConfigBeforeResolvingRegisteredServices() { From df3bda0ecc7ecb13fbde38d9785653f3885c4b71 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:57:51 +0200 Subject: [PATCH 27/98] build(aspnetcore): add SquidStd ASP.NET Core bridge project --- SquidStd.slnx | 3 +++ .../SquidStd.AspNetCore.csproj | 25 +++++++++++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 6 +++++ 3 files changed, 34 insertions(+) create mode 100644 src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj diff --git a/SquidStd.slnx b/SquidStd.slnx index 28b5ceed..dde5c62d 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -1,4 +1,7 @@ + + + diff --git a/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj new file mode 100644 index 00000000..35c863d7 --- /dev/null +++ b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index a2a941d8..918806f3 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -9,11 +9,16 @@ + + + + + @@ -25,6 +30,7 @@ + From 74a9e37190be79491b37dc64a1a112c9620835ed Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:57:52 +0200 Subject: [PATCH 28/98] feat(messaging): add AddInMemoryMessaging DI registration --- .../MessagingRegistrationExtensions.cs | 35 +++++++++++++++++++ .../MessagingRegistrationExtensionsTests.cs | 34 ++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/SquidStd.Messaging/MessagingRegistrationExtensions.cs create mode 100644 tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs diff --git a/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs new file mode 100644 index 00000000..b10314df --- /dev/null +++ b/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs @@ -0,0 +1,35 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging; + +/// +/// DryIoc registration helpers for the in-memory messaging system. +/// +public static class MessagingRegistrationExtensions +{ + /// + /// Registers the in-memory messaging services (facade, provider, serializer, metrics). + /// + /// The container to register into. + /// Optional messaging options; defaults are used when null. + /// The container for chaining. + public static IContainer AddInMemoryMessaging(this IContainer container, MessagingOptions? options = null) + { + ArgumentNullException.ThrowIfNull(container); + + container.RegisterInstance(options ?? new MessagingOptions()); + container.Register(Reuse.Singleton); + + var metrics = new MessagingMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } +} diff --git a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs new file mode 100644 index 00000000..6240d46b --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs @@ -0,0 +1,34 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Messaging; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Tests.Messaging; + +public class MessagingRegistrationExtensionsTests +{ + [Fact] + public void AddInMemoryMessaging_RegistersResolvableServices() + { + using var container = new Container(); + + container.AddInMemoryMessaging(); + + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + + Assert.Contains(container.Resolve>(), p => p.ProviderName == "messaging"); + } + + [Fact] + public void AddInMemoryMessaging_MetricsAndProviderShareInstance() + { + using var container = new Container(); + + container.AddInMemoryMessaging(); + + Assert.Same(container.Resolve(), container.Resolve()); + } +} From 321f7a1c56aa643a5bc858f6522aec398c20eef9 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 13:58:45 +0200 Subject: [PATCH 29/98] feat(aspnetcore): add SquidStd hosted service --- .../Services/SquidStdHostedService.cs | 33 ++++++++++ .../AspNetCore/FakeSquidStdBootstrap.cs | 60 +++++++++++++++++++ .../AspNetCore/SquidStdHostedServiceTests.cs | 19 ++++++ 3 files changed, 112 insertions(+) create mode 100644 src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs create mode 100644 tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs create mode 100644 tests/SquidStd.Tests/AspNetCore/SquidStdHostedServiceTests.cs diff --git a/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs new file mode 100644 index 00000000..158e7d04 --- /dev/null +++ b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Hosting; +using SquidStd.Core.Interfaces.Bootstrap; + +namespace SquidStd.AspNetCore.Services; + +/// +/// Bridges the ASP.NET Core host lifecycle to the SquidStd bootstrap lifecycle. +/// +internal sealed class SquidStdHostedService : IHostedService +{ + private readonly ISquidStdBootstrap _bootstrap; + + /// + /// Initializes the hosted service. + /// + /// SquidStd bootstrap instance started with the ASP.NET host. + public SquidStdHostedService(ISquidStdBootstrap bootstrap) + { + _bootstrap = bootstrap; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + await _bootstrap.StartAsync(cancellationToken); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _bootstrap.StopAsync(cancellationToken); + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs new file mode 100644 index 00000000..52218c51 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -0,0 +1,60 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Bootstrap; + +namespace SquidStd.Tests.AspNetCore; + +internal sealed class FakeSquidStdBootstrap : ISquidStdBootstrap +{ + public int StartCount { get; private set; } + + public int StopCount { get; private set; } + + public SquidStdOptions Options { get; } = new(); + + public IContainer Container { get; } = new Container(); + + public ISquidStdBootstrap ConfigureService(Func configure) + => ConfigureServices(configure); + + public ISquidStdBootstrap ConfigureServices(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var configuredContainer = configure(Container); + + return ReferenceEquals(configuredContainer, Container) + ? this + : throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); + } + + public TService Resolve() + => Container.Resolve(); + + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + StartCount++; + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + StopCount++; + + return ValueTask.CompletedTask; + } + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + await StartAsync(cancellationToken); + } + + public ValueTask DisposeAsync() + { + Container.Dispose(); + + return ValueTask.CompletedTask; + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdHostedServiceTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdHostedServiceTests.cs new file mode 100644 index 00000000..ad72ca88 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdHostedServiceTests.cs @@ -0,0 +1,19 @@ +using SquidStd.AspNetCore.Services; + +namespace SquidStd.Tests.AspNetCore; + +public class SquidStdHostedServiceTests +{ + [Fact] + public async Task StartAsync_StopAsync_DelegatesToBootstrap() + { + await using var bootstrap = new FakeSquidStdBootstrap(); + var service = new SquidStdHostedService(bootstrap); + + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + Assert.Equal(1, bootstrap.StartCount); + Assert.Equal(1, bootstrap.StopCount); + } +} From ad7cda5b3182216df971019964c74c30aac05940 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:00:16 +0200 Subject: [PATCH 30/98] feat(aspnetcore): add WebApplicationBuilder bridge --- .../SquidStdAspNetCoreBuilderExtensions.cs | 71 +++++++++ ...quidStdAspNetCoreBuilderExtensionsTests.cs | 147 ++++++++++++++++++ .../AspNetCore/TestAspNetCoreMarker.cs | 6 + 3 files changed, 224 insertions(+) create mode 100644 src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs create mode 100644 tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs create mode 100644 tests/SquidStd.Tests/AspNetCore/TestAspNetCoreMarker.cs diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs new file mode 100644 index 00000000..8d995a7c --- /dev/null +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs @@ -0,0 +1,71 @@ +using DryIoc; +using DryIoc.Microsoft.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SquidStd.AspNetCore.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; + +namespace SquidStd.AspNetCore.Extensions; + +/// +/// Extension methods for connecting SquidStd to ASP.NET Core Minimal API applications. +/// +public static class SquidStdAspNetCoreBuilderExtensions +{ + /// ASP.NET Core application builder. + extension(WebApplicationBuilder builder) + { + /// + /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. + /// + /// Optional SquidStd bootstrap options callback. + /// The same builder for chaining. + public WebApplicationBuilder UseSquidStd(Action? configureOptions = null) + => builder.UseSquidStd(configureOptions, null); + + /// + /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. + /// + /// Optional SquidStd bootstrap options callback. + /// Optional DryIoc registration callback. + /// The same builder for chaining. + public WebApplicationBuilder UseSquidStd( + Action? configureOptions, + Func? configureContainer + ) + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new SquidStdOptions + { + RootDirectory = builder.Environment.ContentRootPath + }; + configureOptions?.Invoke(options); + ValidateOptions(options); + + var container = new Container(); + builder.Host.UseServiceProviderFactory(new DryIocServiceProviderFactory(container)); + SquidStdBootstrap.Create(options, container); + + var configuredContainer = configureContainer?.Invoke(container) ?? container; + + if (!ReferenceEquals(configuredContainer, container)) + { + throw new InvalidOperationException("ConfigureSquidStdContainer must return the DryIoc container instance."); + } + + builder.Services.AddHostedService(); + + return builder; + } + } + + private static void ValidateOptions(SquidStdOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ConfigName); + ArgumentException.ThrowIfNullOrWhiteSpace(options.RootDirectory); + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs new file mode 100644 index 00000000..2a5f8a9f --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using DryIoc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SquidStd.AspNetCore.Extensions; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.AspNetCore; + +public class SquidStdAspNetCoreBuilderExtensionsTests +{ + [Fact] + public async Task UseSquidStd_RegistersBootstrapWithContentRootDefaults() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + var returned = builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + var bootstrap = app.Services.GetRequiredService(); + + Assert.Same(builder, returned); + Assert.Equal("app", bootstrap.Options.ConfigName); + Assert.Equal(Path.GetFullPath(temp.Path), Path.GetFullPath(bootstrap.Options.RootDirectory)); + } + + [Fact] + public async Task UseSquidStd_ResolvesSquidStdServicesAfterHostStart() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + await app.StartAsync(); + + try + { + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + } + finally + { + await app.StopAsync(); + } + } + + [Fact] + public async Task UseSquidStd_AllowsMinimalApiInjection() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + app.MapGet("/timer", (ITimerService timer) => Results.Ok(timer.GetType().Name)); + + await app.StartAsync(); + + try + { + var client = app.GetTestClient(); + var response = await client.GetAsync("/timer"); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("TimerWheelService", content, StringComparison.Ordinal); + } + finally + { + await app.StopAsync(); + } + } + + [Fact] + public async Task UseSquidStd_AppliesCustomDryIocRegistrations() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + builder.UseSquidStd( + options => options.ConfigName = "app", + container => + { + container.RegisterInstance(new TestAspNetCoreMarker { Value = "custom" }); + + return container; + } + ); + + await using var app = builder.Build(); + + Assert.Equal("custom", app.Services.GetRequiredService().Value); + } + + [Fact] + public void UseSquidStd_WhenContainerCallbackReturnsDifferentContainer_Throws() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + var ex = Assert.Throws( + () => builder.UseSquidStd( + options => options.ConfigName = "app", + _ => new Container() + ) + ); + + Assert.Equal("ConfigureSquidStdContainer must return the DryIoc container instance.", ex.Message); + } + + [Fact] + public void UseSquidStd_WhenOptionsAreInvalid_Throws() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + Assert.Throws( + () => builder.UseSquidStd(options => options.ConfigName = string.Empty) + ); + } + + private static WebApplicationBuilder CreateBuilder(string contentRootPath) + { + var builder = WebApplication.CreateBuilder( + new WebApplicationOptions + { + ContentRootPath = contentRootPath, + EnvironmentName = Environments.Development + } + ); + + builder.WebHost.UseTestServer(); + + return builder; + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/TestAspNetCoreMarker.cs b/tests/SquidStd.Tests/AspNetCore/TestAspNetCoreMarker.cs new file mode 100644 index 00000000..d9d9ee35 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/TestAspNetCoreMarker.cs @@ -0,0 +1,6 @@ +namespace SquidStd.Tests.AspNetCore; + +internal sealed class TestAspNetCoreMarker +{ + public string Value { get; set; } = string.Empty; +} From ec3346ff142e776b90b7161e16e5893bec795889 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:00:40 +0200 Subject: [PATCH 31/98] docs: list SquidStd ASP.NET Core package --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8f4ae6b9..0e97b202 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Short description goes here. | Package | Version | Downloads | |---------|---------|-----------| | `SquidStd.Abstractions` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Abstractions/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Abstractions.svg) | +| `SquidStd.AspNetCore` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.AspNetCore.svg)](https://www.nuget.org/packages/SquidStd.AspNetCore/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.AspNetCore.svg) | | `SquidStd.Core` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Core.svg) | | `SquidStd.Network` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Network.svg)](https://www.nuget.org/packages/SquidStd.Network/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Network.svg) | | `SquidStd.Plugin.Abstractions` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Plugin.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Plugin.Abstractions.svg) | From 08268ccf665c50e0eb65c95516aece53d6f2eda2 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:12:11 +0200 Subject: [PATCH 32/98] feat(database): add SquidStd.Database.Abstractions project skeleton --- SquidStd.slnx | 1 + .../SquidStd.Database.Abstractions.csproj | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj diff --git a/SquidStd.slnx b/SquidStd.slnx index 849f80e1..aac88009 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -6,6 +6,7 @@ + diff --git a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj new file mode 100644 index 00000000..15590ad0 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj @@ -0,0 +1,8 @@ + + + + + + + + From d9856e92ea4c1e29a36fce330c146a037e56512e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:12:39 +0200 Subject: [PATCH 33/98] feat(database): add DatabaseProviderType, BaseEntity and DatabaseConfig --- .../Data/Database/DatabaseConfig.cs | 27 +++++++++++++++++++ .../Data/Entities/BaseEntity.cs | 16 +++++++++++ .../Types/Data/DatabaseProviderType.cs | 19 +++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs create mode 100644 src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs create mode 100644 src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs diff --git a/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs b/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs new file mode 100644 index 00000000..39fdc203 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Database.Abstractions.Data.Database; + +/// +/// Database connection configuration. +/// +public sealed class DatabaseConfig : IConfigEntry +{ + /// + /// Gets or sets the URI-style connection string (e.g. "sqlite://squidstd.db", + /// "postgres://user:pass@host:5432/db"). The scheme selects the provider. + /// + public string ConnectionString { get; set; } = "sqlite://squidstd.db"; + + /// + /// Gets or sets a value indicating whether the schema is auto-synchronized on startup. + /// + public bool AutoMigrate { get; set; } = true; + + string IConfigEntry.SectionName => "database"; + + Type IConfigEntry.ConfigType => typeof(DatabaseConfig); + + object IConfigEntry.CreateDefault() + => new DatabaseConfig(); +} diff --git a/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs b/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs new file mode 100644 index 00000000..aee115e9 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Database.Abstractions.Data.Entities; + +/// +/// Base class for all persisted entities: a Guid identity plus UTC create/update timestamps. +/// +public abstract class BaseEntity +{ + /// Gets or sets the primary key (assigned on insert when empty). + public Guid Id { get; set; } + + /// Gets or sets the UTC creation timestamp (set on insert). + public DateTimeOffset Created { get; set; } + + /// Gets or sets the UTC last-update timestamp (set on insert and update). + public DateTimeOffset Updated { get; set; } +} diff --git a/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs b/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs new file mode 100644 index 00000000..81955055 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs @@ -0,0 +1,19 @@ +namespace SquidStd.Database.Abstractions.Types.Data; + +/// +/// Supported database providers. +/// +public enum DatabaseProviderType +{ + /// SQLite. + Sqlite, + + /// PostgreSQL. + Postgres, + + /// Microsoft SQL Server. + SqlServer, + + /// MySQL. + MySql +} From e5b5162b0fdc345b895764c6e42a762455c97bac Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:13:24 +0200 Subject: [PATCH 34/98] feat(database): add PagedResultData DTO with paging metadata --- .../Data/PagedResultData.cs | 48 +++++++++++++++++++ .../Database/PagedResultDataTests.cs | 43 +++++++++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 3 files changed, 92 insertions(+) create mode 100644 src/SquidStd.Database.Abstractions/Data/PagedResultData.cs create mode 100644 tests/SquidStd.Tests/Database/PagedResultDataTests.cs diff --git a/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs new file mode 100644 index 00000000..30c3f641 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs @@ -0,0 +1,48 @@ +namespace SquidStd.Database.Abstractions.Data; + +/// +/// A paginated result set with paging metadata. +/// +/// The item type. +public sealed class PagedResultData +{ + /// Gets the items on the current page. + public required IReadOnlyList Items { get; init; } + + /// Gets the 1-based page number. + public required int Page { get; init; } + + /// Gets the page size. + public required int PageSize { get; init; } + + /// Gets the total number of matching rows. + public required long TotalCount { get; init; } + + /// Gets the total number of pages. + public int TotalPages => PageSize <= 0 ? 0 : (int)((TotalCount + PageSize - 1) / PageSize); + + /// Gets a value indicating whether a next page exists. + public bool HasNext => Page < TotalPages; + + /// Gets a value indicating whether a previous page exists. + public bool HasPrevious => Page > 1 && TotalPages > 0; + + /// + /// Creates a paged result. + /// + /// The current page items. + /// The 1-based page number. + /// The page size. + /// The total matching row count. + /// The paged result. + public static PagedResultData Create(IReadOnlyList items, int page, int pageSize, long totalCount) + { + return new PagedResultData + { + Items = items, + Page = page, + PageSize = pageSize, + TotalCount = totalCount + }; + } +} diff --git a/tests/SquidStd.Tests/Database/PagedResultDataTests.cs b/tests/SquidStd.Tests/Database/PagedResultDataTests.cs new file mode 100644 index 00000000..abee62ff --- /dev/null +++ b/tests/SquidStd.Tests/Database/PagedResultDataTests.cs @@ -0,0 +1,43 @@ +using SquidStd.Database.Abstractions.Data; + +namespace SquidStd.Tests.Database; + +public class PagedResultDataTests +{ + [Fact] + public void Create_ComputesPagingMetadata() + { + var items = new[] { 1, 2, 3 }; + + var result = PagedResultData.Create(items, page: 2, pageSize: 3, totalCount: 10); + + Assert.Equal(items, result.Items); + Assert.Equal(2, result.Page); + Assert.Equal(3, result.PageSize); + Assert.Equal(10, result.TotalCount); + Assert.Equal(4, result.TotalPages); + Assert.True(result.HasNext); + Assert.True(result.HasPrevious); + } + + [Fact] + public void Create_FirstPageHasNoPrevious() + { + var result = PagedResultData.Create(new[] { 1 }, page: 1, pageSize: 10, totalCount: 1); + + Assert.Equal(1, result.TotalPages); + Assert.False(result.HasNext); + Assert.False(result.HasPrevious); + } + + [Fact] + public void Create_EmptyResultHasZeroPages() + { + var result = PagedResultData.Create(Array.Empty(), page: 1, pageSize: 10, totalCount: 0); + + Assert.Empty(result.Items); + Assert.Equal(0, result.TotalPages); + Assert.False(result.HasNext); + Assert.False(result.HasPrevious); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index fd3f06b2..75ac9e32 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -37,6 +37,7 @@ + From 5b05964a48ac6dd809a58da4ffb8519bfed15666 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:13:49 +0200 Subject: [PATCH 35/98] feat(database): add IDataAccess contract --- .../Interfaces/Data/IDataAccess.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs diff --git a/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs new file mode 100644 index 00000000..5969d8b3 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs @@ -0,0 +1,58 @@ +using System.Linq.Expressions; +using SquidStd.Database.Abstractions.Data; +using SquidStd.Database.Abstractions.Data.Entities; + +namespace SquidStd.Database.Abstractions.Interfaces.Data; + +/// +/// Generic data access for a type: CRUD, bulk, and querying. +/// +/// The entity type. +public interface IDataAccess + where TEntity : BaseEntity +{ + /// Inserts a single entity (assigns Id/Created/Updated) inside a transaction. + Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Gets an entity by its identifier, or null if not found. + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// Updates an entity (bumps Updated) inside a transaction. + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Deletes an entity by id. Returns true if a row was removed. + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + /// Deletes the given entity. Returns true if a row was removed. + Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Counts entities matching the optional predicate. + Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + + /// Returns true if any entity matches the predicate. + Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default); + + /// Bulk-inserts entities inside a transaction. Returns affected rows. + Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// Bulk-updates entities inside a transaction. Returns affected rows. + Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// Bulk-deletes entities matching the predicate inside a transaction. Returns affected rows. + Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default); + + /// Returns a composable, SQL-translated query over the entity set. + IQueryable Query(); + + /// Materializes entities matching the optional predicate. + Task> QueryAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + + /// Returns a page of entities with total-count metadata. + Task> GetPagedAsync( + int page, + int pageSize, + Expression>? predicate = null, + Expression>? orderBy = null, + bool descending = false, + CancellationToken cancellationToken = default); +} From d5f9117ae118669efbfe7673ee4e4f8f5fd7018a Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:14:31 +0200 Subject: [PATCH 36/98] feat(core): add ReplaceEnv regex env-substitution extension --- .../Extensions/Env/EnvExtensions.cs | 29 +++++++++++ tests/SquidStd.Tests/Env/ReplaceEnvTests.cs | 52 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/SquidStd.Tests/Env/ReplaceEnvTests.cs diff --git a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs index 354fb6e5..de4f34fe 100644 --- a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs +++ b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Text.RegularExpressions; namespace SquidStd.Core.Extensions.Env; @@ -28,4 +29,32 @@ public static string ExpandEnvironmentVariables(this string input) return input; } + + /// + /// Replaces "$VAR" tokens with the matching environment variable value. Unknown variables are + /// left unchanged. + /// + /// The input string. + /// The string with known $VAR tokens substituted. + public static string ReplaceEnv(this string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + return EnvTokenRegex.Replace( + input, + match => + { + var name = match.Groups[1].Value; + var value = Environment.GetEnvironmentVariable(name); + + return value ?? match.Value; + }); + } + + private static readonly Regex EnvTokenRegex = new( + @"\$([A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.Compiled); } diff --git a/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs new file mode 100644 index 00000000..5928bdf8 --- /dev/null +++ b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs @@ -0,0 +1,52 @@ +using SquidStd.Core.Extensions.Env; + +namespace SquidStd.Tests.Env; + +public class ReplaceEnvTests +{ + [Fact] + public void ReplaceEnv_SubstitutesKnownVariable() + { + Environment.SetEnvironmentVariable("SQUID_TEST_VAR", "secret"); + try + { + Assert.Equal("pwd=secret;", "pwd=$SQUID_TEST_VAR;".ReplaceEnv()); + } + finally + { + Environment.SetEnvironmentVariable("SQUID_TEST_VAR", null); + } + } + + [Fact] + public void ReplaceEnv_LeavesUnknownVariableUntouched() + { + Environment.SetEnvironmentVariable("SQUID_MISSING_VAR", null); + Assert.Equal("x=$SQUID_MISSING_VAR", "x=$SQUID_MISSING_VAR".ReplaceEnv()); + } + + [Fact] + public void ReplaceEnv_SubstitutesMultipleTokens() + { + Environment.SetEnvironmentVariable("SQUID_A", "1"); + Environment.SetEnvironmentVariable("SQUID_B", "2"); + try + { + Assert.Equal("1-2", "$SQUID_A-$SQUID_B".ReplaceEnv()); + } + finally + { + Environment.SetEnvironmentVariable("SQUID_A", null); + Environment.SetEnvironmentVariable("SQUID_B", null); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("no tokens here")] + public void ReplaceEnv_PassesThroughWhenNothingToReplace(string? input) + { + Assert.Equal(input, input!.ReplaceEnv()); + } +} From b6b7eabb37b78d41d1682f546c1e9184251ce46b Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:16:18 +0200 Subject: [PATCH 37/98] feat(config): substitute $ENV_VAR tokens in string config values on load --- .../Services/ConfigManagerService.cs | 44 +++++++++++++++++++ .../Core/ConfigManagerServiceEnvTests.cs | 37 ++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index 2b6c0032..038d96ef 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -1,6 +1,8 @@ +using System.Reflection; using DryIoc; using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Extensions.Env; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Yaml; @@ -68,6 +70,8 @@ public void Load() : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? entry.CreateDefault(); + ApplyEnvSubstitution(value, new HashSet(ReferenceEqualityComparer.Instance)); + _values[entry.ConfigType] = value; _container.RegisterInstance(entry.ConfigType, value, IfAlreadyRegistered.Replace); } @@ -143,6 +147,46 @@ .. _container.Resolve>() ]; } + private static void ApplyEnvSubstitution(object? instance, HashSet visited) + { + if (instance is null || !visited.Add(instance)) + { + return; + } + + var type = instance.GetType(); + + if (type.Namespace is null || !type.Namespace.StartsWith("SquidStd", StringComparison.Ordinal)) + { + return; + } + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length > 0) + { + continue; + } + + if (property.PropertyType == typeof(string) && property.CanRead && property.CanWrite) + { + var current = (string?)property.GetValue(instance); + + if (!string.IsNullOrEmpty(current)) + { + property.SetValue(instance, current.ReplaceEnv()); + } + + continue; + } + + if (property.PropertyType.IsClass && property.PropertyType != typeof(string) && property.CanRead) + { + ApplyEnvSubstitution(property.GetValue(instance), visited); + } + } + } + private static string ResolveConfigPath(string configName, string configDirectory) { var fileName = Path.HasExtension(configName) ? configName : $"{configName}.yaml"; diff --git a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs new file mode 100644 index 00000000..d15f07ba --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs @@ -0,0 +1,37 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Services.Core; + +public class ConfigManagerServiceEnvTests +{ + [Fact] + public void Load_SubstitutesEnvTokensInStringProperties() + { + var dir = Path.Combine(Path.GetTempPath(), "squidstd-cfg-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable("SQUID_DB_PASS", "p@ss"); + try + { + File.WriteAllText( + Path.Combine(dir, "app.yaml"), + "database:\n ConnectionString: postgres://u:$SQUID_DB_PASS@h:5432/db\n AutoMigrate: true\n"); + + var container = new Container(); + container.RegisterConfigSection("database"); + var service = new ConfigManagerService(container, "app", dir); + + service.Load(); + + var config = container.Resolve(); + Assert.Equal("postgres://u:p@ss@h:5432/db", config.ConnectionString); + } + finally + { + Environment.SetEnvironmentVariable("SQUID_DB_PASS", null); + Directory.Delete(dir, recursive: true); + } + } +} From 5a2eadfcde00281da22ad32fb830bcd07cac7ca4 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:17:06 +0200 Subject: [PATCH 38/98] feat(database): add SquidStd.Database project with FreeSql and ZLinq --- SquidStd.slnx | 1 + src/SquidStd.Database/SquidStd.Database.csproj | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/SquidStd.Database/SquidStd.Database.csproj diff --git a/SquidStd.slnx b/SquidStd.slnx index aac88009..4684d3b2 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -7,6 +7,7 @@ + diff --git a/src/SquidStd.Database/SquidStd.Database.csproj b/src/SquidStd.Database/SquidStd.Database.csproj new file mode 100644 index 00000000..1875458c --- /dev/null +++ b/src/SquidStd.Database/SquidStd.Database.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + From 8649240a02c7a330fe4b426a8976e4659bf6d832 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:19:41 +0200 Subject: [PATCH 39/98] feat(database): add connection-string parser and FreeSql database service --- .../Connection/ConnectionStringParser.cs | 136 ++++++++++++++++++ .../Connection/ParsedConnection.cs | 10 ++ .../Services/DatabaseService.cs | 83 +++++++++++ .../Services/IDatabaseService.cs | 13 ++ .../Database/ConnectionStringParserTests.cs | 76 ++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 6 files changed, 319 insertions(+) create mode 100644 src/SquidStd.Database/Connection/ConnectionStringParser.cs create mode 100644 src/SquidStd.Database/Connection/ParsedConnection.cs create mode 100644 src/SquidStd.Database/Services/DatabaseService.cs create mode 100644 src/SquidStd.Database/Services/IDatabaseService.cs create mode 100644 tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs diff --git a/src/SquidStd.Database/Connection/ConnectionStringParser.cs b/src/SquidStd.Database/Connection/ConnectionStringParser.cs new file mode 100644 index 00000000..89a95a01 --- /dev/null +++ b/src/SquidStd.Database/Connection/ConnectionStringParser.cs @@ -0,0 +1,136 @@ +using SquidStd.Database.Abstractions.Types.Data; + +namespace SquidStd.Database.Connection; + +/// +/// Parses URI-style connection strings ("scheme://...") into a provider and native connection string. +/// +public static class ConnectionStringParser +{ + /// + /// Parses the given URI connection string. + /// + /// The URI connection string. + /// The parsed provider and native connection string. + public static ParsedConnection Parse(string connectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var schemeEnd = connectionString.IndexOf("://", StringComparison.Ordinal); + + if (schemeEnd <= 0) + { + throw new FormatException($"Connection string '{connectionString}' is not a valid URI."); + } + + var scheme = connectionString[..schemeEnd].ToLowerInvariant(); + var remainder = connectionString[(schemeEnd + 3)..]; + var provider = ResolveProvider(scheme); + + var native = provider == DatabaseProviderType.Sqlite + ? BuildSqlite(remainder) + : BuildServer(provider, remainder); + + return new ParsedConnection(provider, native); + } + + private static DatabaseProviderType ResolveProvider(string scheme) + => scheme switch + { + "sqlite" => DatabaseProviderType.Sqlite, + "postgres" or "postgresql" => DatabaseProviderType.Postgres, + "sqlserver" or "mssql" => DatabaseProviderType.SqlServer, + "mysql" => DatabaseProviderType.MySql, + _ => throw new NotSupportedException($"Unsupported database scheme '{scheme}'.") + }; + + private static string BuildSqlite(string remainder) + { + if (string.IsNullOrWhiteSpace(remainder)) + { + throw new FormatException("SQLite connection string requires a file path or ':memory:'."); + } + + return $"Data Source={remainder}"; + } + + private static string BuildServer(DatabaseProviderType provider, string remainder) + { + // Split "[user[:pass]@]host[:port]/database[?query]" into authority and path. + var slash = remainder.IndexOf('/', StringComparison.Ordinal); + + if (slash < 0) + { + throw new FormatException("Connection string requires a database name."); + } + + var authority = remainder[..slash]; + var pathAndQuery = remainder[(slash + 1)..]; + + var query = pathAndQuery.IndexOf('?', StringComparison.Ordinal); + var database = (query < 0 ? pathAndQuery : pathAndQuery[..query]).Trim('/'); + + if (string.IsNullOrEmpty(database)) + { + throw new FormatException("Connection string requires a database name."); + } + + var (user, password, hostPort) = SplitAuthority(authority); + var (host, port) = SplitHostPort(hostPort); + + if (string.IsNullOrEmpty(host)) + { + throw new FormatException("Connection string requires a host."); + } + + return provider switch + { + DatabaseProviderType.Postgres => + $"Host={host};Port={port ?? 5432};Username={user};Password={password};Database={database}", + DatabaseProviderType.MySql => + $"Server={host};Port={port ?? 3306};Uid={user};Pwd={password};Database={database}", + DatabaseProviderType.SqlServer => + $"Server={host},{port ?? 1433};User Id={user};Password={password};Database={database};TrustServerCertificate=true", + _ => throw new NotSupportedException($"Unsupported provider {provider}.") + }; + } + + private static (string User, string Password, string HostPort) SplitAuthority(string authority) + { + var at = authority.LastIndexOf('@'); + + if (at < 0) + { + return (string.Empty, string.Empty, authority); + } + + var userInfo = authority[..at]; + var hostPort = authority[(at + 1)..]; + var separator = userInfo.IndexOf(':', StringComparison.Ordinal); + + if (separator < 0) + { + return (Uri.UnescapeDataString(userInfo), string.Empty, hostPort); + } + + var user = Uri.UnescapeDataString(userInfo[..separator]); + var password = Uri.UnescapeDataString(userInfo[(separator + 1)..]); + + return (user, password, hostPort); + } + + private static (string Host, int? Port) SplitHostPort(string hostPort) + { + var separator = hostPort.IndexOf(':', StringComparison.Ordinal); + + if (separator < 0) + { + return (hostPort, null); + } + + var host = hostPort[..separator]; + var port = int.Parse(hostPort[(separator + 1)..], System.Globalization.CultureInfo.InvariantCulture); + + return (host, port); + } +} diff --git a/src/SquidStd.Database/Connection/ParsedConnection.cs b/src/SquidStd.Database/Connection/ParsedConnection.cs new file mode 100644 index 00000000..b03c0d84 --- /dev/null +++ b/src/SquidStd.Database/Connection/ParsedConnection.cs @@ -0,0 +1,10 @@ +using SquidStd.Database.Abstractions.Types.Data; + +namespace SquidStd.Database.Connection; + +/// +/// The result of parsing a URI connection string: the provider and the native connection string. +/// +/// The resolved database provider. +/// The provider-native connection string for FreeSql. +public sealed record ParsedConnection(DatabaseProviderType Provider, string NativeConnectionString); diff --git a/src/SquidStd.Database/Services/DatabaseService.cs b/src/SquidStd.Database/Services/DatabaseService.cs new file mode 100644 index 00000000..fce6dc64 --- /dev/null +++ b/src/SquidStd.Database/Services/DatabaseService.cs @@ -0,0 +1,83 @@ +using FreeSql; +using Serilog; +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Database.Abstractions.Types.Data; +using SquidStd.Database.Connection; + +namespace SquidStd.Database.Services; + +/// +/// Builds and owns the singleton FreeSql instance, logging SQL and migrations verbosely. +/// +public sealed class DatabaseService : IDatabaseService +{ + private static readonly ILogger Logger = Log.ForContext(); + + private readonly DatabaseConfig _config; + private IFreeSql? _orm; + private int _started; + + /// + public IFreeSql Orm + => _orm ?? throw new InvalidOperationException("Database service is not started."); + + /// + /// Initializes the database service. + /// + /// The database configuration section. + public DatabaseService(DatabaseConfig config) + { + _config = config; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (Interlocked.Exchange(ref _started, 1) != 0) + { + return ValueTask.CompletedTask; + } + + var parsed = ConnectionStringParser.Parse(_config.ConnectionString); + Logger.Verbose("Building FreeSql for provider {Provider}", parsed.Provider); + + var builder = new FreeSqlBuilder() + .UseConnectionString(MapDataType(parsed.Provider), parsed.NativeConnectionString) + .UseAutoSyncStructure(_config.AutoMigrate) + .UseMonitorCommand(cmd => Logger.Verbose("SQL {Sql}", cmd.CommandText)); + + _orm = builder.Build(); + _orm.Aop.SyncStructureAfter += (_, e) => + Logger.Verbose("Migrated {Entities} -> {Sql}", e.EntityTypes, e.Sql); + + Logger.Information( + "Database service started ({Provider}, autoMigrate={AutoMigrate})", + parsed.Provider, + _config.AutoMigrate); + + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + _orm?.Dispose(); + _orm = null; + + return ValueTask.CompletedTask; + } + + private static DataType MapDataType(DatabaseProviderType provider) + => provider switch + { + DatabaseProviderType.Sqlite => DataType.Sqlite, + DatabaseProviderType.Postgres => DataType.PostgreSQL, + DatabaseProviderType.SqlServer => DataType.SqlServer, + DatabaseProviderType.MySql => DataType.MySql, + _ => throw new NotSupportedException($"Unsupported provider {provider}.") + }; +} diff --git a/src/SquidStd.Database/Services/IDatabaseService.cs b/src/SquidStd.Database/Services/IDatabaseService.cs new file mode 100644 index 00000000..ef96e8e8 --- /dev/null +++ b/src/SquidStd.Database/Services/IDatabaseService.cs @@ -0,0 +1,13 @@ +using FreeSql; +using SquidStd.Abstractions.Interfaces.Services; + +namespace SquidStd.Database.Services; + +/// +/// Owns the application's singleton FreeSql instance and its lifecycle. +/// +public interface IDatabaseService : ISquidStdService +{ + /// Gets the underlying FreeSql ORM instance. + IFreeSql Orm { get; } +} diff --git a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs new file mode 100644 index 00000000..d536854f --- /dev/null +++ b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs @@ -0,0 +1,76 @@ +using SquidStd.Database.Abstractions.Types.Data; +using SquidStd.Database.Connection; + +namespace SquidStd.Tests.Database; + +public class ConnectionStringParserTests +{ + [Fact] + public void Parse_SqliteRelativePath() + { + var result = ConnectionStringParser.Parse("sqlite://app.db"); + Assert.Equal(DatabaseProviderType.Sqlite, result.Provider); + Assert.Equal("Data Source=app.db", result.NativeConnectionString); + } + + [Fact] + public void Parse_SqliteAbsolutePath() + { + var result = ConnectionStringParser.Parse("sqlite:///var/data/app.db"); + Assert.Equal("Data Source=/var/data/app.db", result.NativeConnectionString); + } + + [Fact] + public void Parse_SqliteInMemory() + { + var result = ConnectionStringParser.Parse("sqlite://:memory:"); + Assert.Equal("Data Source=:memory:", result.NativeConnectionString); + } + + [Fact] + public void Parse_Postgres_BuildsNativeString() + { + var result = ConnectionStringParser.Parse("postgres://user:pass@db.host:5433/appdb"); + Assert.Equal(DatabaseProviderType.Postgres, result.Provider); + Assert.Equal("Host=db.host;Port=5433;Username=user;Password=pass;Database=appdb", + result.NativeConnectionString); + } + + [Fact] + public void Parse_Postgres_DefaultsPortAndDecodesCredentials() + { + var result = ConnectionStringParser.Parse("postgresql://u%40corp:p%3Aw@h/appdb"); + Assert.Equal("Host=h;Port=5432;Username=u@corp;Password=p:w;Database=appdb", + result.NativeConnectionString); + } + + [Fact] + public void Parse_MySql_BuildsNativeString() + { + var result = ConnectionStringParser.Parse("mysql://root:secret@127.0.0.1:3307/shop"); + Assert.Equal(DatabaseProviderType.MySql, result.Provider); + Assert.Equal("Server=127.0.0.1;Port=3307;Uid=root;Pwd=secret;Database=shop", + result.NativeConnectionString); + } + + [Fact] + public void Parse_SqlServer_BuildsNativeString() + { + var result = ConnectionStringParser.Parse("sqlserver://sa:Str0ng@localhost:1433/master"); + Assert.Equal(DatabaseProviderType.SqlServer, result.Provider); + Assert.Equal("Server=localhost,1433;User Id=sa;Password=Str0ng;Database=master;TrustServerCertificate=true", + result.NativeConnectionString); + } + + [Fact] + public void Parse_UnknownScheme_Throws() + { + Assert.Throws(() => ConnectionStringParser.Parse("oracle://h/db")); + } + + [Fact] + public void Parse_MissingHostForServerProvider_Throws() + { + Assert.Throws(() => ConnectionStringParser.Parse("postgres:///db")); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 75ac9e32..92ba4899 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -38,6 +38,7 @@ + From 676ce91ef1547054e7c00124621a1bc0b91a7e5e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:20:06 +0200 Subject: [PATCH 40/98] feat(database): add ZLinq in-memory result helpers --- .../Extensions/ZLinqResultExtensions.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs diff --git a/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs new file mode 100644 index 00000000..219b9cdf --- /dev/null +++ b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs @@ -0,0 +1,39 @@ +using ZLinq; + +namespace SquidStd.Database.Extensions; + +/// +/// Zero-allocation, in-memory helpers (ZLinq) over already-materialized result lists. +/// +public static class ZLinqResultExtensions +{ + /// + /// Projects each materialized item to a new form using ZLinq, returning a list. + /// + /// The source item type. + /// The projected item type. + /// The materialized source items. + /// The projection. + /// The projected list. + public static List MapToList( + this IReadOnlyList source, + Func selector) + { + return source.AsValueEnumerable().Select(selector).ToList(); + } + + /// + /// Takes an in-memory page of a materialized list using ZLinq (no SQL involved). + /// + /// The item type. + /// The materialized source items. + /// The 1-based page number. + /// The page size. + /// The page items. + public static List PageInMemory(this IReadOnlyList source, int page, int pageSize) + { + var skip = Math.Max(0, (page - 1) * pageSize); + + return source.AsValueEnumerable().Skip(skip).Take(pageSize).ToList(); + } +} From f6db123f1884147bfa7b04e8a8de2aff2572685a Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:23:15 +0200 Subject: [PATCH 41/98] chore(solution): organize solution folders - chore: group all source projects under the src solution folder - chore: group test projects under the tests solution folder --- SquidStd.slnx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/SquidStd.slnx b/SquidStd.slnx index 849f80e1..ec4beb3f 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -1,14 +1,16 @@ + + + + + + + + + - - - - - - - From 4a73a594f92d4c9e8f09b0898ca4603c59e97f51 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:25:56 +0200 Subject: [PATCH 42/98] feat(database): add FreeSql data access with bulk ops and transactional writes - IDataAccess.Query() returns FreeSql ISelect (FreeSql has no IQueryable bridge) - add FreeSql.DbContext for UnitOfWork transactions; sync schema outside transaction - abstractions reference FreeSql core for ISelect --- .../Interfaces/Data/IDataAccess.cs | 5 +- .../SquidStd.Database.Abstractions.csproj | 4 + .../Data/FreeSqlDataAccess.cs | 251 ++++++++++++++++++ .../SquidStd.Database.csproj | 1 + .../Database/FreeSqlDataAccessTests.cs | 152 +++++++++++ 5 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 src/SquidStd.Database/Data/FreeSqlDataAccess.cs create mode 100644 tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs diff --git a/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs index 5969d8b3..587633e4 100644 --- a/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs +++ b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using FreeSql; using SquidStd.Database.Abstractions.Data; using SquidStd.Database.Abstractions.Data.Entities; @@ -41,8 +42,8 @@ public interface IDataAccess /// Bulk-deletes entities matching the predicate inside a transaction. Returns affected rows. Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default); - /// Returns a composable, SQL-translated query over the entity set. - IQueryable Query(); + /// Returns a composable, SQL-translated query (FreeSql ISelect) over the entity set. + ISelect Query(); /// Materializes entities matching the optional predicate. Task> QueryAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); diff --git a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj index 15590ad0..152521f4 100644 --- a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj +++ b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj @@ -5,4 +5,8 @@ + + + + diff --git a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs new file mode 100644 index 00000000..4e9fbed9 --- /dev/null +++ b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs @@ -0,0 +1,251 @@ +using System.Linq.Expressions; +using FreeSql; +using Serilog; +using SquidStd.Database.Abstractions.Data; +using SquidStd.Database.Abstractions.Data.Entities; +using SquidStd.Database.Abstractions.Interfaces.Data; +using SquidStd.Database.Services; + +namespace SquidStd.Database.Data; + +/// +/// FreeSql-backed . Writes run inside a unit of work with rollback. +/// +/// The entity type. +public sealed class FreeSqlDataAccess : IDataAccess + where TEntity : BaseEntity +{ + private static readonly ILogger Logger = Log.ForContext>(); + + private readonly IFreeSql _orm; + + /// + /// Initializes the data access over the shared FreeSql instance. + /// + /// The database service that owns the FreeSql instance. + public FreeSqlDataAccess(IDatabaseService databaseService) + { + _orm = databaseService.Orm; + } + + /// + public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + + if (entity.Id == Guid.Empty) + { + entity.Id = Guid.CreateVersion7(); + } + + entity.Created = now; + entity.Updated = now; + + await RunInTransactionAsync( + transaction => _orm.Insert(entity).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "Insert", + 1, + cancellationToken); + + return entity; + } + + /// + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _orm.Select().Where(e => e.Id == id).FirstAsync(cancellationToken)!; + + /// + public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + entity.Updated = DateTimeOffset.UtcNow; + + await RunInTransactionAsync( + transaction => _orm.Update().SetSource(entity).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "Update", + 1, + cancellationToken); + + return entity; + } + + /// + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + var affected = await RunInTransactionAsync( + transaction => _orm.Delete().Where(e => e.Id == id).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "Delete", + null, + cancellationToken); + + return affected > 0; + } + + /// + public Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) + => DeleteAsync(entity.Id, cancellationToken); + + /// + public Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) + { + var select = _orm.Select(); + + if (predicate is not null) + { + select = select.Where(predicate); + } + + return select.CountAsync(cancellationToken); + } + + /// + public Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default) + => _orm.Select().Where(predicate).AnyAsync(cancellationToken); + + /// + public Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + var list = Materialize(entities, stampUpdated: true); + + return RunInTransactionAsync( + transaction => _orm.Insert(list).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "BulkInsert", + list.Count, + cancellationToken); + } + + /// + public Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + var list = entities.ToList(); + var now = DateTimeOffset.UtcNow; + + foreach (var entity in list) + { + entity.Updated = now; + } + + return RunInTransactionAsync( + transaction => _orm.Update().SetSource(list).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "BulkUpdate", + list.Count, + cancellationToken); + } + + /// + public Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default) + => RunInTransactionAsync( + transaction => _orm.Delete().Where(predicate).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "BulkDelete", + null, + cancellationToken); + + /// + public ISelect Query() + => _orm.Select(); + + /// + public async Task> QueryAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) + { + var select = _orm.Select(); + + if (predicate is not null) + { + select = select.Where(predicate); + } + + return await select.ToListAsync(cancellationToken); + } + + /// + public async Task> GetPagedAsync( + int page, + int pageSize, + Expression>? predicate = null, + Expression>? orderBy = null, + bool descending = false, + CancellationToken cancellationToken = default) + { + var select = _orm.Select(); + + if (predicate is not null) + { + select = select.Where(predicate); + } + + var total = await select.CountAsync(cancellationToken); + + if (orderBy is not null) + { + select = descending ? select.OrderByDescending(orderBy) : select.OrderBy(orderBy); + } + + var items = await select.Page(page, pageSize).ToListAsync(cancellationToken); + + return PagedResultData.Create(items, page, pageSize, total); + } + + private void EnsureSynced() + { + // Create/alter the table outside any transaction (idempotent, cached by FreeSql) so that + // SQLite DDL never collides with an open unit-of-work file lock. + if (_orm.CodeFirst.IsAutoSyncStructure) + { + _orm.CodeFirst.SyncStructure(); + } + } + + private static List Materialize(IEnumerable entities, bool stampUpdated) + { + var now = DateTimeOffset.UtcNow; + var list = entities.ToList(); + + foreach (var entity in list) + { + if (entity.Id == Guid.Empty) + { + entity.Id = Guid.CreateVersion7(); + } + + entity.Created = now; + + if (stampUpdated) + { + entity.Updated = now; + } + } + + return list; + } + + private async Task RunInTransactionAsync( + Func> action, + string operation, + int? expectedCount, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + EnsureSynced(); + + using var uow = _orm.CreateUnitOfWork(); + + try + { + var transaction = uow.GetOrBeginTransaction(); + Logger.Verbose("{Operation} starting (expected {Expected})", operation, expectedCount); + + var affected = await action(transaction); + + uow.Commit(); + Logger.Verbose("{Operation} committed ({Affected} rows)", operation, affected); + + return affected; + } + catch (Exception ex) + { + uow.Rollback(); + Logger.Error(ex, "{Operation} rolled back", operation); + throw; + } + } +} diff --git a/src/SquidStd.Database/SquidStd.Database.csproj b/src/SquidStd.Database/SquidStd.Database.csproj index 1875458c..211f330d 100644 --- a/src/SquidStd.Database/SquidStd.Database.csproj +++ b/src/SquidStd.Database/SquidStd.Database.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs new file mode 100644 index 00000000..04b42344 --- /dev/null +++ b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs @@ -0,0 +1,152 @@ +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Database.Abstractions.Data.Entities; +using SquidStd.Database.Data; +using SquidStd.Database.Services; + +namespace SquidStd.Tests.Database; + +public sealed class SampleUser : BaseEntity +{ + public string Name { get; set; } = string.Empty; + public int Age { get; set; } +} + +public class FreeSqlDataAccessTests : IAsyncLifetime +{ + private string _dbPath = string.Empty; + private DatabaseService _service = null!; + + public async Task InitializeAsync() + { + _dbPath = Path.Combine(Path.GetTempPath(), "squidstd-db-" + Guid.NewGuid().ToString("N") + ".db"); + _service = new DatabaseService(new DatabaseConfig + { + ConnectionString = $"sqlite://{_dbPath}", + AutoMigrate = true + }); + await _service.StartAsync(); + } + + public async Task DisposeAsync() + { + await _service.StopAsync(); + + if (File.Exists(_dbPath)) + { + File.Delete(_dbPath); + } + } + + private FreeSqlDataAccess NewAccess() + => new(_service); + + [Fact] + public async Task InsertAsync_SetsIdAndTimestamps() + { + var access = NewAccess(); + var inserted = await access.InsertAsync(new SampleUser { Name = "Ann", Age = 30 }); + + Assert.NotEqual(Guid.Empty, inserted.Id); + Assert.NotEqual(default, inserted.Created); + Assert.Equal(inserted.Created, inserted.Updated); + + var fetched = await access.GetByIdAsync(inserted.Id); + Assert.NotNull(fetched); + Assert.Equal("Ann", fetched!.Name); + } + + [Fact] + public async Task UpdateAsync_BumpsUpdated() + { + var access = NewAccess(); + var user = await access.InsertAsync(new SampleUser { Name = "Bob", Age = 20 }); + + user.Age = 21; + var updated = await access.UpdateAsync(user); + + Assert.Equal(21, updated.Age); + Assert.True(updated.Updated >= updated.Created); + } + + [Fact] + public async Task DeleteAsync_RemovesRow() + { + var access = NewAccess(); + var user = await access.InsertAsync(new SampleUser { Name = "Cara", Age = 40 }); + + Assert.True(await access.DeleteAsync(user.Id)); + Assert.Null(await access.GetByIdAsync(user.Id)); + Assert.False(await access.DeleteAsync(user.Id)); + } + + [Fact] + public async Task CountAndExists_RespectPredicate() + { + var access = NewAccess(); + await access.BulkInsertAsync(new[] + { + new SampleUser { Name = "x", Age = 10 }, + new SampleUser { Name = "y", Age = 50 } + }); + + Assert.Equal(2, await access.CountAsync()); + Assert.Equal(1, await access.CountAsync(u => u.Age >= 50)); + Assert.True(await access.ExistsAsync(u => u.Age == 10)); + Assert.False(await access.ExistsAsync(u => u.Age == 999)); + } + + [Fact] + public async Task BulkUpdateAndDelete_AffectRows() + { + var access = NewAccess(); + var users = new List + { + new() { Name = "a", Age = 1 }, + new() { Name = "b", Age = 2 }, + new() { Name = "c", Age = 3 } + }; + await access.BulkInsertAsync(users); + + foreach (var u in users) + { + u.Age += 100; + } + + Assert.Equal(3, await access.BulkUpdateAsync(users)); + Assert.Equal(3, await access.CountAsync(u => u.Age > 100)); + Assert.Equal(3, await access.BulkDeleteAsync(u => u.Age > 100)); + Assert.Equal(0, await access.CountAsync()); + } + + [Fact] + public async Task GetPagedAsync_ReturnsMetadata() + { + var access = NewAccess(); + for (var i = 0; i < 25; i++) + { + await access.InsertAsync(new SampleUser { Name = $"u{i}", Age = i }); + } + + var page = await access.GetPagedAsync(page: 2, pageSize: 10, orderBy: u => u.Age); + + Assert.Equal(10, page.Items.Count); + Assert.Equal(25, page.TotalCount); + Assert.Equal(3, page.TotalPages); + Assert.True(page.HasNext); + Assert.True(page.HasPrevious); + Assert.Equal(10, page.Items[0].Age); + } + + [Fact] + public async Task InsertAsync_RollsBackOnFailure() + { + var access = NewAccess(); + var first = await access.InsertAsync(new SampleUser { Name = "first", Age = 1 }); + + // Re-using an existing primary key forces a unique-constraint violation. + var duplicate = new SampleUser { Id = first.Id, Name = "dup", Age = 2 }; + + await Assert.ThrowsAnyAsync(() => access.InsertAsync(duplicate)); + Assert.Equal(1, await access.CountAsync()); + } +} From 56a1f610e9686b3d55ef3dc1e1d87656efa14c1e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:26:14 +0200 Subject: [PATCH 43/98] feat(database): add RegisterDatabase DI extension --- .../Extensions/RegisterDatabaseExtension.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs diff --git a/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs new file mode 100644 index 00000000..7998f074 --- /dev/null +++ b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs @@ -0,0 +1,29 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Database.Abstractions.Interfaces.Data; +using SquidStd.Database.Data; +using SquidStd.Database.Services; + +namespace SquidStd.Database.Extensions; + +/// +/// DI registration for the database subsystem. +/// +public static class RegisterDatabaseExtension +{ + /// + /// Registers the database config section, the database service, and the open-generic data access. + /// + /// The DI container. + /// The same container for chaining. + public static IContainer RegisterDatabase(this IContainer container) + { + container.RegisterConfigSection("database"); + container.RegisterStdService(); + container.Register(typeof(IDataAccess<>), typeof(FreeSqlDataAccess<>), Reuse.Transient); + + return container; + } +} From a289fefc5f2790e028783869f2ce80048ac7c7ff Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:27:07 +0200 Subject: [PATCH 44/98] ci(database): publish SquidStd.Database packages on release --- .github/workflows/nuget-publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 5f70bc98..f5c836c4 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -53,6 +53,8 @@ jobs: "src/SquidStd.Network/SquidStd.Network.csproj" "src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj" "src/SquidStd.Services.Core/SquidStd.Services.Core.csproj" + "src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj" + "src/SquidStd.Database/SquidStd.Database.csproj" ) for project in "${packages[@]}"; do echo "Packing ${project}" From 10265e6fb218bd4652656ab69a08aeceead8571b Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:30:44 +0200 Subject: [PATCH 45/98] ci(database): discover publishable projects via property Replace the hand-maintained pack list in nuget-publish.yml with discovery of projects opting in through true in their csproj. --- .github/workflows/nuget-publish.yml | 14 +++++--------- .../SquidStd.Abstractions.csproj | 1 + src/SquidStd.Core/SquidStd.Core.csproj | 1 + .../SquidStd.Database.Abstractions.csproj | 4 ++++ src/SquidStd.Database/SquidStd.Database.csproj | 4 ++++ src/SquidStd.Network/SquidStd.Network.csproj | 1 + .../SquidStd.Plugin.Abstractions.csproj | 1 + .../SquidStd.Services.Core.csproj | 1 + 8 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index f5c836c4..3207c162 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -47,15 +47,11 @@ jobs: - name: Pack publishable packages run: | set -euo pipefail - packages=( - "src/SquidStd.Abstractions/SquidStd.Abstractions.csproj" - "src/SquidStd.Core/SquidStd.Core.csproj" - "src/SquidStd.Network/SquidStd.Network.csproj" - "src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj" - "src/SquidStd.Services.Core/SquidStd.Services.Core.csproj" - "src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj" - "src/SquidStd.Database/SquidStd.Database.csproj" - ) + # Discover every project opted-in with true. + mapfile -t packages < <(grep -ril --include='*.csproj' 'true' src | sort) + if [[ ${#packages[@]} -eq 0 ]]; then + echo "No project has true"; exit 1 + fi for project in "${packages[@]}"; do echo "Packing ${project}" dotnet pack "${project}" -c Release --no-build diff --git a/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj b/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj index 8e1a1b5d..d214510d 100644 --- a/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj +++ b/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj @@ -1,6 +1,7 @@  + true net10.0 enable enable diff --git a/src/SquidStd.Core/SquidStd.Core.csproj b/src/SquidStd.Core/SquidStd.Core.csproj index f601c589..3f12457d 100644 --- a/src/SquidStd.Core/SquidStd.Core.csproj +++ b/src/SquidStd.Core/SquidStd.Core.csproj @@ -1,6 +1,7 @@  + true net10.0 enable enable diff --git a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj index 152521f4..abc70949 100644 --- a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj +++ b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj @@ -1,5 +1,9 @@ + + true + + diff --git a/src/SquidStd.Database/SquidStd.Database.csproj b/src/SquidStd.Database/SquidStd.Database.csproj index 211f330d..4293db11 100644 --- a/src/SquidStd.Database/SquidStd.Database.csproj +++ b/src/SquidStd.Database/SquidStd.Database.csproj @@ -1,5 +1,9 @@ + + true + + diff --git a/src/SquidStd.Network/SquidStd.Network.csproj b/src/SquidStd.Network/SquidStd.Network.csproj index 2dc22e59..80883ec6 100644 --- a/src/SquidStd.Network/SquidStd.Network.csproj +++ b/src/SquidStd.Network/SquidStd.Network.csproj @@ -1,6 +1,7 @@  + true net10.0 enable enable diff --git a/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj b/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj index 8e1a1b5d..d214510d 100644 --- a/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj +++ b/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj @@ -1,6 +1,7 @@  + true net10.0 enable enable diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index a6b0a0ca..5d12649d 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -1,6 +1,7 @@  + true net10.0 enable enable From bf4f680b9033ad190a5a5e18f21f62c6401c580d Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:35:48 +0200 Subject: [PATCH 46/98] refactor(messaging): move shared facade/serializer/metrics into Abstractions --- .../JsonMessageSerializer.cs | 2 +- .../MessageQueue.cs | 2 +- .../MessagingMetricsProvider.cs | 2 +- tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs | 2 +- tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename src/{SquidStd.Messaging => SquidStd.Messaging.Abstractions}/JsonMessageSerializer.cs (96%) rename src/{SquidStd.Messaging => SquidStd.Messaging.Abstractions}/MessageQueue.cs (97%) rename src/{SquidStd.Messaging => SquidStd.Messaging.Abstractions}/MessagingMetricsProvider.cs (98%) diff --git a/src/SquidStd.Messaging/JsonMessageSerializer.cs b/src/SquidStd.Messaging.Abstractions/JsonMessageSerializer.cs similarity index 96% rename from src/SquidStd.Messaging/JsonMessageSerializer.cs rename to src/SquidStd.Messaging.Abstractions/JsonMessageSerializer.cs index 19a5bd36..e6585f40 100644 --- a/src/SquidStd.Messaging/JsonMessageSerializer.cs +++ b/src/SquidStd.Messaging.Abstractions/JsonMessageSerializer.cs @@ -2,7 +2,7 @@ using System.Text.Json; using SquidStd.Messaging.Abstractions.Interfaces; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Abstractions; /// /// Default JSON message serializer based on System.Text.Json. diff --git a/src/SquidStd.Messaging/MessageQueue.cs b/src/SquidStd.Messaging.Abstractions/MessageQueue.cs similarity index 97% rename from src/SquidStd.Messaging/MessageQueue.cs rename to src/SquidStd.Messaging.Abstractions/MessageQueue.cs index 0c2c30ca..d4f23dd4 100644 --- a/src/SquidStd.Messaging/MessageQueue.cs +++ b/src/SquidStd.Messaging.Abstractions/MessageQueue.cs @@ -1,6 +1,6 @@ using SquidStd.Messaging.Abstractions.Interfaces; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Abstractions; /// /// Typed facade over an : serializes outgoing messages and diff --git a/src/SquidStd.Messaging/MessagingMetricsProvider.cs b/src/SquidStd.Messaging.Abstractions/MessagingMetricsProvider.cs similarity index 98% rename from src/SquidStd.Messaging/MessagingMetricsProvider.cs rename to src/SquidStd.Messaging.Abstractions/MessagingMetricsProvider.cs index 25a3d9d5..8a6a3dde 100644 --- a/src/SquidStd.Messaging/MessagingMetricsProvider.cs +++ b/src/SquidStd.Messaging.Abstractions/MessagingMetricsProvider.cs @@ -4,7 +4,7 @@ using SquidStd.Core.Types.Metrics; using SquidStd.Messaging.Abstractions.Interfaces; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Abstractions; /// /// Accumulates messaging metrics and exposes them to the metrics collection system. diff --git a/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs b/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs index b8458155..fde12e63 100644 --- a/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs +++ b/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs @@ -1,4 +1,4 @@ -using SquidStd.Messaging; +using SquidStd.Messaging.Abstractions; using SquidStd.Messaging.Abstractions.Interfaces; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs index dbe4a10c..9285dc37 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs @@ -1,4 +1,4 @@ -using SquidStd.Messaging; +using SquidStd.Messaging.Abstractions; namespace SquidStd.Tests.Messaging; From b83bff02c32378d63c6da3f261d8c333fd233927 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:37:00 +0200 Subject: [PATCH 47/98] feat(messaging): add scheme-based MessagingConnectionString and in-memory url overload --- .../MessagingConnectionString.cs | 97 +++++++++++++++++++ .../MessagingRegistrationExtensions.cs | 20 ++++ .../MessagingConnectionStringTests.cs | 54 +++++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/SquidStd.Messaging.Abstractions/MessagingConnectionString.cs create mode 100644 tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs diff --git a/src/SquidStd.Messaging.Abstractions/MessagingConnectionString.cs b/src/SquidStd.Messaging.Abstractions/MessagingConnectionString.cs new file mode 100644 index 00000000..4f5b3e81 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/MessagingConnectionString.cs @@ -0,0 +1,97 @@ +using System.Collections.Frozen; +using System.Web; + +namespace SquidStd.Messaging.Abstractions; + +/// +/// Parsed messaging connection string of the form scheme://[user:pass@]host[:port][/vhost][?params]. +/// +public sealed class MessagingConnectionString +{ + private MessagingConnectionString( + string scheme, + string host, + int? port, + string? userName, + string? password, + string virtualHost, + IReadOnlyDictionary parameters + ) + { + Scheme = scheme; + Host = host; + Port = port; + UserName = userName; + Password = password; + VirtualHost = virtualHost; + Parameters = parameters; + } + + /// URI scheme, e.g. "memory" or "rabbitmq". + public string Scheme { get; } + + /// Host component. + public string Host { get; } + + /// Port, when specified. + public int? Port { get; } + + /// User name from the user-info component, when present. + public string? UserName { get; } + + /// Password from the user-info component, when present. + public string? Password { get; } + + /// Virtual host from the path (default "/"). + public string VirtualHost { get; } + + /// Query-string parameters. + public IReadOnlyDictionary Parameters { get; } + + /// Parses a messaging connection string. + public static MessagingConnectionString Parse(string connectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var uri = new Uri(connectionString, UriKind.Absolute); + + string? userName = null; + string? password = null; + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var parts = uri.UserInfo.Split(':', 2); + userName = Uri.UnescapeDataString(parts[0]); + password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : null; + } + + var virtualHost = uri.AbsolutePath.Trim('/'); + var query = HttpUtility.ParseQueryString(uri.Query); + var parameters = query.AllKeys + .Where(static key => key is not null) + .ToFrozenDictionary(key => key!, key => query[key] ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + return new( + uri.Scheme, + uri.Host, + uri.Port > 0 ? uri.Port : null, + userName, + password, + string.IsNullOrEmpty(virtualHost) ? "/" : virtualHost, + parameters + ); + } + + /// Builds from the query parameters. + public MessagingOptions ToMessagingOptions() + => new() + { + MaxDeliveryAttempts = Parameters.TryGetValue("maxDeliveryAttempts", out var max) && int.TryParse(max, out var parsedMax) + ? parsedMax + : 3, + DeadLetterQueueSuffix = Parameters.TryGetValue("deadLetterSuffix", out var suffix) ? suffix : ".dlq", + RetryDelay = Parameters.TryGetValue("retryDelayMs", out var delay) && int.TryParse(delay, out var parsedDelay) + ? TimeSpan.FromMilliseconds(parsedDelay) + : TimeSpan.Zero + }; +} diff --git a/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs index b10314df..941a4fb2 100644 --- a/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs @@ -32,4 +32,24 @@ public static IContainer AddInMemoryMessaging(this IContainer container, Messagi return container; } + + /// + /// Registers the in-memory messaging services from a connection string (scheme must be "memory"). + /// + public static IContainer AddInMemoryMessaging(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = MessagingConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "memory", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'memory://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + return container.AddInMemoryMessaging(cs.ToMessagingOptions()); + } } diff --git a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs new file mode 100644 index 00000000..20be409e --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs @@ -0,0 +1,54 @@ +using SquidStd.Messaging.Abstractions; + +namespace SquidStd.Tests.Messaging; + +public class MessagingConnectionStringTests +{ + [Fact] + public void Parse_Memory_ReadsScheme() + { + var cs = MessagingConnectionString.Parse("memory://localhost"); + + Assert.Equal("memory", cs.Scheme); + Assert.Equal("localhost", cs.Host); + Assert.Null(cs.Port); + } + + [Fact] + public void Parse_RabbitMq_ReadsCredentialsHostPortVhost() + { + var cs = MessagingConnectionString.Parse("rabbitmq://user:pass@broker:5672/myvhost"); + + Assert.Equal("rabbitmq", cs.Scheme); + Assert.Equal("user", cs.UserName); + Assert.Equal("pass", cs.Password); + Assert.Equal("broker", cs.Host); + Assert.Equal(5672, cs.Port); + Assert.Equal("myvhost", cs.VirtualHost); + } + + [Fact] + public void Parse_ReadsQueryParameters() + { + var cs = MessagingConnectionString.Parse("rabbitmq://broker/?prefetch=20&maxDeliveryAttempts=5"); + + Assert.Equal("20", cs.Parameters["prefetch"]); + Assert.Equal("5", cs.Parameters["maxDeliveryAttempts"]); + } + + [Fact] + public void ToMessagingOptions_ReadsParameters() + { + var cs = MessagingConnectionString.Parse("rabbitmq://broker/?maxDeliveryAttempts=5&deadLetterSuffix=.dead&retryDelayMs=250"); + + var options = cs.ToMessagingOptions(); + + Assert.Equal(5, options.MaxDeliveryAttempts); + Assert.Equal(".dead", options.DeadLetterQueueSuffix); + Assert.Equal(TimeSpan.FromMilliseconds(250), options.RetryDelay); + } + + [Fact] + public void Parse_NullOrWhitespace_Throws() + => Assert.Throws(() => MessagingConnectionString.Parse(" ")); +} From f3deddbc925c52b86537db76718e90471db1dc1b Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:38:49 +0200 Subject: [PATCH 48/98] feat(messaging): add RabbitMq queue provider and registration --- SquidStd.slnx | 1 + ...RabbitMqMessagingRegistrationExtensions.cs | 75 +++++ .../RabbitMqOptions.cs | 28 ++ .../RabbitMqQueueProvider.cs | 271 ++++++++++++++++++ .../SquidStd.Messaging.RabbitMq.csproj | 18 ++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 2 + 6 files changed, 395 insertions(+) create mode 100644 src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs create mode 100644 src/SquidStd.Messaging.RabbitMq/RabbitMqOptions.cs create mode 100644 src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs create mode 100644 src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj diff --git a/SquidStd.slnx b/SquidStd.slnx index 0a9d9597..39c21dd0 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -6,6 +6,7 @@ + diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs new file mode 100644 index 00000000..92beed8b --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs @@ -0,0 +1,75 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging.RabbitMq; + +/// +/// DryIoc registration helpers for the RabbitMQ messaging provider. +/// +public static class RabbitMqMessagingRegistrationExtensions +{ + /// Registers RabbitMQ messaging from an explicit options object. + public static IContainer AddRabbitMqMessaging( + this IContainer container, + RabbitMqOptions options, + MessagingOptions? messagingOptions = null + ) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + + var resolvedMessaging = messagingOptions ?? new MessagingOptions(); + + container.RegisterInstance(resolvedMessaging); + container.RegisterInstance(options); + container.Register(Reuse.Singleton); + + var metrics = new MessagingMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.RegisterDelegate( + r => new RabbitMqQueueProvider( + r.Resolve(), + r.Resolve(), + r.Resolve() + ), + Reuse.Singleton + ); + container.Register(Reuse.Singleton); + + return container; + } + + /// Registers RabbitMQ messaging from a connection string (scheme must be "rabbitmq"). + public static IContainer AddRabbitMqMessaging(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = MessagingConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "rabbitmq", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'rabbitmq://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + var options = new RabbitMqOptions + { + HostName = string.IsNullOrEmpty(cs.Host) ? "localhost" : cs.Host, + Port = cs.Port ?? 5672, + VirtualHost = cs.VirtualHost, + UserName = cs.UserName ?? "guest", + Password = cs.Password ?? "guest", + PrefetchCount = cs.Parameters.TryGetValue("prefetch", out var prefetch) && ushort.TryParse(prefetch, out var parsed) + ? parsed + : (ushort)10 + }; + + return container.AddRabbitMqMessaging(options, cs.ToMessagingOptions()); + } +} diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqOptions.cs b/src/SquidStd.Messaging.RabbitMq/RabbitMqOptions.cs new file mode 100644 index 00000000..cc642674 --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/RabbitMqOptions.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Messaging.RabbitMq; + +/// +/// Connection options for the RabbitMQ queue provider. +/// +public sealed class RabbitMqOptions +{ + /// Broker host. Default "localhost". + public string HostName { get; init; } = "localhost"; + + /// Broker port. Default 5672. + public int Port { get; init; } = 5672; + + /// Virtual host. Default "/". + public string VirtualHost { get; init; } = "/"; + + /// User name. Default "guest". + public string UserName { get; init; } = "guest"; + + /// Password. Default "guest". + public string Password { get; init; } = "guest"; + + /// When set, the AMQP URI overrides the individual fields. + public Uri? Uri { get; init; } + + /// Consumer prefetch count. Default 10. + public ushort PrefetchCount { get; init; } = 10; +} diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs b/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs new file mode 100644 index 00000000..83473fd0 --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs @@ -0,0 +1,271 @@ +using System.Collections.Concurrent; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Serilog; +using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging.RabbitMq; + +/// +/// RabbitMQ : named queues map to quorum queues with a delivery limit +/// and a dead-letter exchange; round-robin is the broker's native competing-consumers behaviour. +/// +public sealed class RabbitMqQueueProvider : IQueueProvider +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly RabbitMqOptions _options; + private readonly MessagingOptions _messagingOptions; + private readonly IMessagingMetrics _metrics; + private readonly SemaphoreSlim _publishLock = new(1, 1); + private readonly Lock _topologySync = new(); + private readonly HashSet _declared = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _subscriberCounts = new(StringComparer.Ordinal); + private IConnection? _connection; + private IChannel? _publishChannel; + private int _disposed; + + public RabbitMqQueueProvider(RabbitMqOptions options, MessagingOptions messagingOptions, IMessagingMetrics? metrics = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(messagingOptions); + + _options = options; + _messagingOptions = messagingOptions; + _metrics = metrics ?? NoOpMessagingMetrics.Instance; + } + + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + var factory = new ConnectionFactory { AutomaticRecoveryEnabled = true }; + + if (_options.Uri is not null) + { + factory.Uri = _options.Uri; + } + else + { + factory.HostName = _options.HostName; + factory.Port = _options.Port; + factory.VirtualHost = _options.VirtualHost; + factory.UserName = _options.UserName; + factory.Password = _options.Password; + } + + _connection = await factory.CreateConnectionAsync(cancellationToken); + _publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + + /// + public async Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + var channel = _publishChannel ?? throw new InvalidOperationException("Provider not started."); + + await EnsureTopologyAsync(channel, queueName, cancellationToken); + + var properties = new BasicProperties { Persistent = true }; + + await _publishLock.WaitAsync(cancellationToken); + + try + { + await channel.BasicPublishAsync( + exchange: string.Empty, + routingKey: queueName, + mandatory: false, + basicProperties: properties, + body: payload, + cancellationToken: cancellationToken + ); + } + finally + { + _publishLock.Release(); + } + + _metrics.OnPublished(queueName); + } + + /// + public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + ArgumentNullException.ThrowIfNull(handler); + + var connection = _connection ?? throw new InvalidOperationException("Provider not started."); + var subscription = new Subscription(this, connection, queueName, handler); + subscription.Start(); + _metrics.SetSubscriberCount(queueName, _subscriberCounts.AddOrUpdate(queueName, 1, static (_, count) => count + 1)); + + return subscription; + } + + private async Task EnsureTopologyAsync(IChannel channel, string queueName, CancellationToken cancellationToken) + { + lock (_topologySync) + { + if (!_declared.Add(queueName)) + { + return; + } + } + + // Dead-letter queues are terminal: declare them plainly, no DLX. + if (queueName.EndsWith(_messagingOptions.DeadLetterQueueSuffix, StringComparison.Ordinal)) + { + await channel.QueueDeclareAsync( + queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary { ["x-queue-type"] = "quorum" }, + cancellationToken: cancellationToken + ); + + return; + } + + var deadLetterQueue = queueName + _messagingOptions.DeadLetterQueueSuffix; + + await channel.QueueDeclareAsync( + queue: deadLetterQueue, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary { ["x-queue-type"] = "quorum" }, + cancellationToken: cancellationToken + ); + + await channel.QueueDeclareAsync( + queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + ["x-queue-type"] = "quorum", + ["x-delivery-limit"] = _messagingOptions.MaxDeliveryAttempts, + ["x-dead-letter-exchange"] = string.Empty, + ["x-dead-letter-routing-key"] = deadLetterQueue + }, + cancellationToken: cancellationToken + ); + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_publishChannel is not null) + { + await _publishChannel.CloseAsync(); + await _publishChannel.DisposeAsync(); + } + + if (_connection is not null) + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + _publishLock.Dispose(); + } + + private sealed class Subscription : IDisposable + { + private readonly RabbitMqQueueProvider _provider; + private readonly IConnection _connection; + private readonly string _queueName; + private readonly Func, CancellationToken, Task> _handler; + private IChannel? _channel; + private string? _consumerTag; + private int _disposed; + + public Subscription( + RabbitMqQueueProvider provider, + IConnection connection, + string queueName, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _connection = connection; + _queueName = queueName; + _handler = handler; + } + + public void Start() + => StartAsync().GetAwaiter().GetResult(); + + private async Task StartAsync() + { + _channel = await _connection.CreateChannelAsync(); + await _provider.EnsureTopologyAsync(_channel, _queueName, CancellationToken.None); + await _channel.BasicQosAsync(0, _provider._options.PrefetchCount, false); + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.ReceivedAsync += OnReceivedAsync; + + _consumerTag = await _channel.BasicConsumeAsync(_queueName, autoAck: false, consumer: consumer); + } + + private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) + { + var channel = _channel!; + + try + { + await _handler(args.Body, CancellationToken.None); + await channel.BasicAckAsync(args.DeliveryTag, multiple: false); + _provider._metrics.OnDelivered(_queueName); + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "RabbitMq handler failed for {QueueName}", _queueName); + _provider._metrics.OnFailed(_queueName); + await channel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: true); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_channel is not null) + { + try + { + if (_consumerTag is not null) + { + _channel.BasicCancelAsync(_consumerTag).GetAwaiter().GetResult(); + } + + _channel.CloseAsync().GetAwaiter().GetResult(); + _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best-effort teardown. + } + } + + _provider._metrics.SetSubscriberCount( + _queueName, + _provider._subscriberCounts.AddOrUpdate(_queueName, 0, static (_, count) => Math.Max(0, count - 1)) + ); + } + } +} diff --git a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj new file mode 100644 index 00000000..79f2b1fa --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 92ba4899..b9b49e9d 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -11,6 +11,7 @@ + @@ -39,6 +40,7 @@ + From 48eda72f9f6e5709beab5065bc64526fc4bcce19 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:41:07 +0200 Subject: [PATCH 49/98] test(messaging): add RabbitMq integration tests with Testcontainers --- .../RabbitMq/RabbitMqContainerFixture.cs | 25 ++++++ .../RabbitMq/RabbitMqQueueProviderTests.cs | 79 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs create mode 100644 tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs new file mode 100644 index 00000000..64acc41a --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs @@ -0,0 +1,25 @@ +using Testcontainers.RabbitMq; + +namespace SquidStd.Tests.Messaging.RabbitMq; + +/// +/// Starts a RabbitMQ container once for the whole collection and exposes its AMQP URI. +/// +public sealed class RabbitMqContainerFixture : IAsyncLifetime +{ + private readonly RabbitMqContainer _container = new RabbitMqBuilder().Build(); + + public string AmqpUri => _container.GetConnectionString(); + + public Task InitializeAsync() + => _container.StartAsync(); + + public Task DisposeAsync() + => _container.DisposeAsync().AsTask(); +} + +[CollectionDefinition(Name)] +public sealed class RabbitMqCollection : ICollectionFixture +{ + public const string Name = "RabbitMq"; +} diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs new file mode 100644 index 00000000..5e175f82 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs @@ -0,0 +1,79 @@ +using System.Text; +using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.RabbitMq; + +namespace SquidStd.Tests.Messaging.RabbitMq; + +[Collection(RabbitMqCollection.Name)] +public class RabbitMqQueueProviderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private readonly RabbitMqContainerFixture _fixture; + + public RabbitMqQueueProviderTests(RabbitMqContainerFixture fixture) + { + _fixture = fixture; + } + + private RabbitMqQueueProvider NewProvider(MessagingOptions? options = null) + => new(new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) }, options ?? new MessagingOptions()); + + private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); + private static string Text(ReadOnlyMemory b) => Encoding.UTF8.GetString(b.Span); + private static string Queue() => "q-" + Guid.NewGuid().ToString("N"); + + [Fact] + public async Task Publish_DeliversToSubscriber() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var queue = Queue(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe(queue, (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync(queue, Bytes("hello")); + + Assert.Equal("hello", await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task TwoSubscribers_ShareTheLoad() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var queue = Queue(); + const int total = 10; + var a = 0; + var b = 0; + using var done = new CountdownEvent(total); + provider.Subscribe(queue, (_, _) => { Interlocked.Increment(ref a); done.Signal(); return Task.CompletedTask; }); + provider.Subscribe(queue, (_, _) => { Interlocked.Increment(ref b); done.Signal(); return Task.CompletedTask; }); + + for (var i = 0; i < total; i++) + { + await provider.PublishAsync(queue, Bytes("m")); + } + + Assert.True(done.Wait(Timeout)); + Assert.Equal(total, a + b); + Assert.True(a > 0); + Assert.True(b > 0); + } + + [Fact] + public async Task AlwaysFailing_IsDeadLettered() + { + await using var provider = NewProvider(new MessagingOptions { MaxDeliveryAttempts = 2 }); + await provider.StartAsync(); + var queue = Queue(); + provider.Subscribe(queue, (_, _) => throw new InvalidOperationException("always")); + + var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe(queue + ".dlq", (payload, _) => { deadLettered.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync(queue, Bytes("poison")); + + Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); + } +} From 4c5b90946e3d4fa096c294021512aa1dd67cb696 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:42:35 +0200 Subject: [PATCH 50/98] docs(convention): use string.Empty instead of "" (align with project standard) --- CODE_CONVENTION.md | 4 ++-- .../{ => Interfaces}/Services/IDatabaseService.cs | 0 .../{ => Data/Config}/MessagingOptions.cs | 0 .../{ => Services}/NoOpMessagingMetrics.cs | 0 .../{ => Extensions}/MessagingRegistrationExtensions.cs | 0 .../{ => Services}/InMemoryQueueProvider.cs | 0 .../{ => Services}/JsonMessageSerializer.cs | 0 src/SquidStd.Messaging/{ => Services}/MessageQueue.cs | 0 .../{ => Services}/MessagingMetricsProvider.cs | 0 9 files changed, 2 insertions(+), 2 deletions(-) rename src/SquidStd.Database/{ => Interfaces}/Services/IDatabaseService.cs (100%) rename src/SquidStd.Messaging.Abstractions/{ => Data/Config}/MessagingOptions.cs (100%) rename src/SquidStd.Messaging.Abstractions/{ => Services}/NoOpMessagingMetrics.cs (100%) rename src/SquidStd.Messaging/{ => Extensions}/MessagingRegistrationExtensions.cs (100%) rename src/SquidStd.Messaging/{ => Services}/InMemoryQueueProvider.cs (100%) rename src/SquidStd.Messaging/{ => Services}/JsonMessageSerializer.cs (100%) rename src/SquidStd.Messaging/{ => Services}/MessageQueue.cs (100%) rename src/SquidStd.Messaging/{ => Services}/MessagingMetricsProvider.cs (100%) diff --git a/CODE_CONVENTION.md b/CODE_CONVENTION.md index 43b9f66a..8f318fcf 100644 --- a/CODE_CONVENTION.md +++ b/CODE_CONVENTION.md @@ -99,7 +99,7 @@ public enum DirectoryType { Scripts, Logs, Plugins, Configs } ## 7. Strings -- Always use `""` instead of `string.Empty`. +- Always use `string.Empty` instead of `""`. ## 8. Logging @@ -201,7 +201,7 @@ tests/SquidStd.Tests/Support/FakeSourcePlugin.cs → namespace SquidStd.Tests - No TODO comments without a tracked follow-up. - No inconsistent naming across domains. - Keep warnings under control; do not normalize noisy warnings. -- No `string.Empty` — use `""`. +- No literal `""` — use `string.Empty`. - No primary constructors. - No expression-bodied constructors. diff --git a/src/SquidStd.Database/Services/IDatabaseService.cs b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs similarity index 100% rename from src/SquidStd.Database/Services/IDatabaseService.cs rename to src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs diff --git a/src/SquidStd.Messaging.Abstractions/MessagingOptions.cs b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs similarity index 100% rename from src/SquidStd.Messaging.Abstractions/MessagingOptions.cs rename to src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs diff --git a/src/SquidStd.Messaging.Abstractions/NoOpMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs similarity index 100% rename from src/SquidStd.Messaging.Abstractions/NoOpMessagingMetrics.cs rename to src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs diff --git a/src/SquidStd.Messaging/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs similarity index 100% rename from src/SquidStd.Messaging/MessagingRegistrationExtensions.cs rename to src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs diff --git a/src/SquidStd.Messaging/InMemoryQueueProvider.cs b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs similarity index 100% rename from src/SquidStd.Messaging/InMemoryQueueProvider.cs rename to src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs diff --git a/src/SquidStd.Messaging/JsonMessageSerializer.cs b/src/SquidStd.Messaging/Services/JsonMessageSerializer.cs similarity index 100% rename from src/SquidStd.Messaging/JsonMessageSerializer.cs rename to src/SquidStd.Messaging/Services/JsonMessageSerializer.cs diff --git a/src/SquidStd.Messaging/MessageQueue.cs b/src/SquidStd.Messaging/Services/MessageQueue.cs similarity index 100% rename from src/SquidStd.Messaging/MessageQueue.cs rename to src/SquidStd.Messaging/Services/MessageQueue.cs diff --git a/src/SquidStd.Messaging/MessagingMetricsProvider.cs b/src/SquidStd.Messaging/Services/MessagingMetricsProvider.cs similarity index 100% rename from src/SquidStd.Messaging/MessagingMetricsProvider.cs rename to src/SquidStd.Messaging/Services/MessagingMetricsProvider.cs From b296b617059f7876b5c88213a7572a0bed823f85 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:42:35 +0200 Subject: [PATCH 51/98] refactor(database): move IDatabaseService under Interfaces/Services namespace --- src/SquidStd.Database/Data/FreeSqlDataAccess.cs | 2 +- src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs | 1 + src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs | 2 +- src/SquidStd.Database/Services/DatabaseService.cs | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs index 4e9fbed9..feb89b40 100644 --- a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs +++ b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs @@ -4,7 +4,7 @@ using SquidStd.Database.Abstractions.Data; using SquidStd.Database.Abstractions.Data.Entities; using SquidStd.Database.Abstractions.Interfaces.Data; -using SquidStd.Database.Services; +using SquidStd.Database.Interfaces.Services; namespace SquidStd.Database.Data; diff --git a/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs index 7998f074..fa4f99bf 100644 --- a/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs +++ b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs @@ -4,6 +4,7 @@ using SquidStd.Database.Abstractions.Data.Database; using SquidStd.Database.Abstractions.Interfaces.Data; using SquidStd.Database.Data; +using SquidStd.Database.Interfaces.Services; using SquidStd.Database.Services; namespace SquidStd.Database.Extensions; diff --git a/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs index ef96e8e8..b9253e53 100644 --- a/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs +++ b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs @@ -1,7 +1,7 @@ using FreeSql; using SquidStd.Abstractions.Interfaces.Services; -namespace SquidStd.Database.Services; +namespace SquidStd.Database.Interfaces.Services; /// /// Owns the application's singleton FreeSql instance and its lifecycle. diff --git a/src/SquidStd.Database/Services/DatabaseService.cs b/src/SquidStd.Database/Services/DatabaseService.cs index fce6dc64..6a095358 100644 --- a/src/SquidStd.Database/Services/DatabaseService.cs +++ b/src/SquidStd.Database/Services/DatabaseService.cs @@ -3,6 +3,7 @@ using SquidStd.Database.Abstractions.Data.Database; using SquidStd.Database.Abstractions.Types.Data; using SquidStd.Database.Connection; +using SquidStd.Database.Interfaces.Services; namespace SquidStd.Database.Services; From bfbebd44680f022cb62330da4434789fb4638716 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:42:35 +0200 Subject: [PATCH 52/98] refactor(messaging): organize types into Data.Config/Services/Extensions namespace buckets --- .../Data/Config/MessagingOptions.cs | 2 +- .../Services/NoOpMessagingMetrics.cs | 2 +- .../Extensions/MessagingRegistrationExtensions.cs | 5 +++-- .../Services/InMemoryQueueProvider.cs | 5 +++-- .../Services/JsonMessageSerializer.cs | 10 +++++----- src/SquidStd.Messaging/Services/MessageQueue.cs | 2 +- .../Services/MessagingMetricsProvider.cs | 2 +- .../Messaging/InMemoryQueueProviderTests.cs | 6 ++++-- .../Messaging/JsonMessageSerializerTests.cs | 3 ++- tests/SquidStd.Tests/Messaging/MessageQueueTests.cs | 6 ++++-- .../Messaging/MessagingMetricsProviderTests.cs | 3 ++- .../SquidStd.Tests/Messaging/MessagingOptionsTests.cs | 3 ++- .../Messaging/MessagingRegistrationExtensionsTests.cs | 3 ++- 13 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs index 390bbc5f..97afa226 100644 --- a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs +++ b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs @@ -1,4 +1,4 @@ -namespace SquidStd.Messaging.Abstractions; +namespace SquidStd.Messaging.Abstractions.Data.Config; /// /// Configuration for the messaging system. diff --git a/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs index c036c9f0..b6c7bf71 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs @@ -1,6 +1,6 @@ using SquidStd.Messaging.Abstractions.Interfaces; -namespace SquidStd.Messaging.Abstractions; +namespace SquidStd.Messaging.Abstractions.Services; /// /// Metrics sink that ignores all events. Used when no metrics are configured. diff --git a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs index b10314df..b04e312a 100644 --- a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs @@ -1,9 +1,10 @@ using DryIoc; using SquidStd.Core.Interfaces.Metrics; -using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Services; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Extensions; /// /// DryIoc registration helpers for the in-memory messaging system. diff --git a/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs index e8eeba32..82bf7c66 100644 --- a/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs +++ b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs @@ -1,10 +1,11 @@ using System.Collections.Concurrent; using Serilog; -using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Abstractions.Services; using SquidStd.Messaging.Internal; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Services; /// /// In-memory : one buffered channel + consumer loop per named queue, diff --git a/src/SquidStd.Messaging/Services/JsonMessageSerializer.cs b/src/SquidStd.Messaging/Services/JsonMessageSerializer.cs index 19a5bd36..daad3001 100644 --- a/src/SquidStd.Messaging/Services/JsonMessageSerializer.cs +++ b/src/SquidStd.Messaging/Services/JsonMessageSerializer.cs @@ -2,7 +2,7 @@ using System.Text.Json; using SquidStd.Messaging.Abstractions.Interfaces; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Services; /// /// Default JSON message serializer based on System.Text.Json. @@ -16,13 +16,13 @@ public JsonMessageSerializer() _options = new(JsonSerializerDefaults.Web); } - [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] - [RequiresDynamicCode("JSON serialization may require runtime code generation.")] + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed."), + RequiresDynamicCode("JSON serialization may require runtime code generation.")] public ReadOnlyMemory Serialize(TMessage message) => JsonSerializer.SerializeToUtf8Bytes(message, _options); - [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] - [RequiresDynamicCode("JSON deserialization may require runtime code generation.")] + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed."), + RequiresDynamicCode("JSON deserialization may require runtime code generation.")] public TMessage Deserialize(ReadOnlyMemory payload) => JsonSerializer.Deserialize(payload.Span, _options) ?? throw new InvalidOperationException($"Deserialization returned null for type {typeof(TMessage).Name}."); diff --git a/src/SquidStd.Messaging/Services/MessageQueue.cs b/src/SquidStd.Messaging/Services/MessageQueue.cs index 0c2c30ca..7fbb963d 100644 --- a/src/SquidStd.Messaging/Services/MessageQueue.cs +++ b/src/SquidStd.Messaging/Services/MessageQueue.cs @@ -1,6 +1,6 @@ using SquidStd.Messaging.Abstractions.Interfaces; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Services; /// /// Typed facade over an : serializes outgoing messages and diff --git a/src/SquidStd.Messaging/Services/MessagingMetricsProvider.cs b/src/SquidStd.Messaging/Services/MessagingMetricsProvider.cs index 25a3d9d5..6b0efe9f 100644 --- a/src/SquidStd.Messaging/Services/MessagingMetricsProvider.cs +++ b/src/SquidStd.Messaging/Services/MessagingMetricsProvider.cs @@ -4,7 +4,7 @@ using SquidStd.Core.Types.Metrics; using SquidStd.Messaging.Abstractions.Interfaces; -namespace SquidStd.Messaging; +namespace SquidStd.Messaging.Services; /// /// Accumulates messaging metrics and exposes them to the metrics collection system. diff --git a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs index c0ae71bb..0ea816cf 100644 --- a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs @@ -1,6 +1,8 @@ using System.Text; -using SquidStd.Messaging; -using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Services; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs b/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs index b8458155..58406831 100644 --- a/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs +++ b/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs @@ -1,4 +1,5 @@ -using SquidStd.Messaging; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; using SquidStd.Messaging.Abstractions.Interfaces; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs index 2b05209b..fb6d348c 100644 --- a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs @@ -1,5 +1,7 @@ -using SquidStd.Messaging; -using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Services; using SquidStd.Messaging.Abstractions.Interfaces; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs index dbe4a10c..fb05ae1b 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs @@ -1,4 +1,5 @@ -using SquidStd.Messaging; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs index 378fc9c0..733c3884 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs @@ -1,4 +1,5 @@ -using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Services; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs index 6240d46b..44592d4c 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs @@ -1,6 +1,7 @@ using DryIoc; using SquidStd.Core.Interfaces.Metrics; -using SquidStd.Messaging; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; using SquidStd.Messaging.Abstractions.Interfaces; namespace SquidStd.Tests.Messaging; From 6510f950c1b4f2d2616ffd9bbd995ecfe0628279 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 14:55:32 +0200 Subject: [PATCH 53/98] build: adjust NuGet packaging and trim FreeSql providers - enable PublishNuget on SquidStd.AspNetCore - normalize project properties on Database and Database.Abstractions csproj - keep only the Sqlite FreeSql provider, comment out MySql/PostgreSQL/SqlServer --- .../SquidStd.AspNetCore.csproj | 1 + .../SquidStd.Database.Abstractions.csproj | 24 +++++++------ .../SquidStd.Database.csproj | 36 ++++++++++--------- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj index 35c863d7..994d0dbb 100644 --- a/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj +++ b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj @@ -1,6 +1,7 @@ + true net10.0 enable enable diff --git a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj index abc70949..31ba5cd6 100644 --- a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj +++ b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj @@ -1,16 +1,20 @@ - - true - + + true + net10.0 + enable + enable + true + - - - - + + + + - - - + + + diff --git a/src/SquidStd.Database/SquidStd.Database.csproj b/src/SquidStd.Database/SquidStd.Database.csproj index 4293db11..c1629ed3 100644 --- a/src/SquidStd.Database/SquidStd.Database.csproj +++ b/src/SquidStd.Database/SquidStd.Database.csproj @@ -1,22 +1,26 @@ - - true - + + true + net10.0 + enable + enable + true + - - - + + + - - - - - - - - - - + + + + + + + + + + From b5925164c7fba39571f039226d051addb486204e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 15:11:31 +0200 Subject: [PATCH 54/98] =?UTF-8?q?refactor(network):=20move=20Dispose/Dispo?= =?UTF-8?q?seAsync=20to=20end=20of=20class=20per=20convention=20=C2=A74.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => Data/Config}/RabbitMqOptions.cs | 0 .../Client/SquidStdTcpClient.cs | 56 +++++++++---------- .../Client/SquidStdUdpClient.cs | 52 ++++++++--------- .../Server/SquidStdUdpServer.cs | 16 +++--- src/SquidStd.Network/Server/SquidTcpServer.cs | 16 +++--- .../Sessions/SessionManager.cs | 26 ++++----- src/SquidStd.Network/Spans/SpanReader.cs | 14 ++--- src/SquidStd.Network/Spans/SpanWriter.cs | 24 ++++---- 8 files changed, 102 insertions(+), 102 deletions(-) rename src/SquidStd.Messaging.RabbitMq/{ => Data/Config}/RabbitMqOptions.cs (100%) diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqOptions.cs b/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs similarity index 100% rename from src/SquidStd.Messaging.RabbitMq/RabbitMqOptions.cs rename to src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs diff --git a/src/SquidStd.Network/Client/SquidStdTcpClient.cs b/src/SquidStd.Network/Client/SquidStdTcpClient.cs index 411f2236..b409dbb3 100644 --- a/src/SquidStd.Network/Client/SquidStdTcpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdTcpClient.cs @@ -288,34 +288,6 @@ public bool ContainsMiddleware() where TMiddleware : INetMiddleware => _middlewarePipeline.ContainsMiddleware(); - /// - public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - { - await CloseAsync(CancellationToken.None); - - // Drain the receive loop before disposing the resources it relies on. - if (_receiveLoopTask is not null) - { - try - { - await _receiveLoopTask; - } - catch - { - // Loop failures are already surfaced via OnException. - } - } - - await _stream.DisposeAsync(); - _sendLock.Dispose(); - _internalCancellationTokenSource.Dispose(); - _socket.Dispose(); - } - /// /// Returns a snapshot of recent received bytes from the circular history buffer. /// @@ -602,4 +574,32 @@ private void ReleasePendingBuffer() _pendingBuffer = null; _pendingLength = 0; } + + /// + public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + { + await CloseAsync(CancellationToken.None); + + // Drain the receive loop before disposing the resources it relies on. + if (_receiveLoopTask is not null) + { + try + { + await _receiveLoopTask; + } + catch + { + // Loop failures are already surfaced via OnException. + } + } + + await _stream.DisposeAsync(); + _sendLock.Dispose(); + _internalCancellationTokenSource.Dispose(); + _socket.Dispose(); + } } diff --git a/src/SquidStd.Network/Client/SquidStdUdpClient.cs b/src/SquidStd.Network/Client/SquidStdUdpClient.cs index 7c54350a..e37d9a3d 100644 --- a/src/SquidStd.Network/Client/SquidStdUdpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdUdpClient.cs @@ -120,32 +120,6 @@ public async Task CloseAsync(CancellationToken cancellationToken = default) RaiseDisconnected(); } - /// - public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - { - await CloseAsync(CancellationToken.None); - - // Drain the receive loop before disposing the resources it relies on. - if (_receiveLoopTask is not null) - { - try - { - await _receiveLoopTask; - } - catch - { - // Loop failures are already surfaced via OnException. - } - } - - _internalCancellationTokenSource.Dispose(); - _udpClient.Dispose(); - } - /// /// Sends a datagram to the configured default remote endpoint. /// @@ -266,4 +240,30 @@ private async Task ReceiveLoopAsync() await CloseAsync(CancellationToken.None); } } + + /// + public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + { + await CloseAsync(CancellationToken.None); + + // Drain the receive loop before disposing the resources it relies on. + if (_receiveLoopTask is not null) + { + try + { + await _receiveLoopTask; + } + catch + { + // Loop failures are already surfaced via OnException. + } + } + + _internalCancellationTokenSource.Dispose(); + _udpClient.Dispose(); + } } diff --git a/src/SquidStd.Network/Server/SquidStdUdpServer.cs b/src/SquidStd.Network/Server/SquidStdUdpServer.cs index 598c53a4..cee1cc3d 100644 --- a/src/SquidStd.Network/Server/SquidStdUdpServer.cs +++ b/src/SquidStd.Network/Server/SquidStdUdpServer.cs @@ -107,14 +107,6 @@ public SquidStdUdpServer(IPEndPoint endPoint, bool bindAllInterfaces = true) _bindAllInterfaces = bindAllInterfaces; } - /// - public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); - /// /// Starts listening, binding sockets and launching a receive loop per socket. Recreates the /// sockets on every call, so Stop/Start cycles are supported. @@ -321,4 +313,12 @@ .. NetworkUtils.GetListeningAddresses(_endPoint) .Select(address => new IPEndPoint(address.Address, _endPoint.Port)) ]; } + + /// + public void Dispose() + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + => await StopAsync(CancellationToken.None); } diff --git a/src/SquidStd.Network/Server/SquidTcpServer.cs b/src/SquidStd.Network/Server/SquidTcpServer.cs index 4d6b59f1..c4f71715 100644 --- a/src/SquidStd.Network/Server/SquidTcpServer.cs +++ b/src/SquidStd.Network/Server/SquidTcpServer.cs @@ -109,14 +109,6 @@ public SquidTcpServer AddMiddleware(INetMiddleware middleware) return this; } - /// - public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); - /// /// Starts accepting clients. Recreates the listening socket on every call, /// so Stop/Start cycles are supported. @@ -312,4 +304,12 @@ private void WireClientEvents(SquidStdTcpClient client) OnClientDisconnect?.Invoke(this, args); }; } + + /// + public void Dispose() + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + => await StopAsync(CancellationToken.None); } diff --git a/src/SquidStd.Network/Sessions/SessionManager.cs b/src/SquidStd.Network/Sessions/SessionManager.cs index 4f0d4b39..86a448e1 100644 --- a/src/SquidStd.Network/Sessions/SessionManager.cs +++ b/src/SquidStd.Network/Sessions/SessionManager.cs @@ -68,19 +68,6 @@ public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken ? session.CloseAsync(cancellationToken) : Task.CompletedTask; - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _server.OnClientConnect -= HandleServerClientConnect; - _server.OnClientDisconnect -= HandleServerClientDisconnect; - _server.OnDataReceived -= HandleServerDataReceived; - _sessions.Clear(); - } - /// public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) => _sessions.TryGetValue(sessionId, out var session) @@ -186,4 +173,17 @@ CancellationToken cancellationToken _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); } } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _server.OnClientConnect -= HandleServerClientConnect; + _server.OnClientDisconnect -= HandleServerClientDisconnect; + _server.OnDataReceived -= HandleServerDataReceived; + _sessions.Clear(); + } } diff --git a/src/SquidStd.Network/Spans/SpanReader.cs b/src/SquidStd.Network/Spans/SpanReader.cs index dd89efb1..3168a69e 100644 --- a/src/SquidStd.Network/Spans/SpanReader.cs +++ b/src/SquidStd.Network/Spans/SpanReader.cs @@ -21,13 +21,6 @@ public SpanReader(ReadOnlySpan span) Length = span.Length; } - public void Dispose() - { - _buffer = default; - Position = 0; - Length = 0; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public int Read(scoped Span bytes) { @@ -398,4 +391,11 @@ private static int IndexOfTerminator(ReadOnlySpan span, int terminatorWidt [DoesNotReturn] private static void ThrowInsufficientData() => throw new InvalidOperationException("Insufficient data in buffer."); + + public void Dispose() + { + _buffer = default; + Position = 0; + Length = 0; + } } diff --git a/src/SquidStd.Network/Spans/SpanWriter.cs b/src/SquidStd.Network/Spans/SpanWriter.cs index 6f9bf8e2..1672a363 100644 --- a/src/SquidStd.Network/Spans/SpanWriter.cs +++ b/src/SquidStd.Network/Spans/SpanWriter.cs @@ -94,18 +94,6 @@ public void Clear(int count) Position += count; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Dispose() - { - var toReturn = _arrayToReturnToPool; - this = default; - - if (toReturn is not null) - { - ArrayPool.Shared.Return(toReturn); - } - } - public void EnsureCapacity(int capacity) { if (capacity > _buffer.Length) @@ -442,4 +430,16 @@ private void GrowIfNeeded(int count) Grow(count); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + var toReturn = _arrayToReturnToPool; + this = default; + + if (toReturn is not null) + { + ArrayPool.Shared.Return(toReturn); + } + } } From 91d66ce24e818df77e65ae3f8fcded9c0e8340d6 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 15:11:32 +0200 Subject: [PATCH 55/98] refactor(messaging-rabbitmq): move RabbitMqOptions into Data.Config namespace bucket --- src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs | 2 +- .../RabbitMqMessagingRegistrationExtensions.cs | 1 + src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs b/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs index cc642674..4e939ab7 100644 --- a/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs +++ b/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs @@ -1,4 +1,4 @@ -namespace SquidStd.Messaging.RabbitMq; +namespace SquidStd.Messaging.RabbitMq.Data.Config; /// /// Connection options for the RabbitMQ queue provider. diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs index c9ac2e1f..c2b65951 100644 --- a/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs @@ -4,6 +4,7 @@ using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Abstractions.Services; +using SquidStd.Messaging.RabbitMq.Data.Config; namespace SquidStd.Messaging.RabbitMq; /// diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs b/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs index 07aa1d69..bd0a19b7 100644 --- a/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs +++ b/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs @@ -6,6 +6,7 @@ using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Abstractions.Services; +using SquidStd.Messaging.RabbitMq.Data.Config; namespace SquidStd.Messaging.RabbitMq; /// From ee81bfe8f977a982b4cecf3a5329de04da0ec4b9 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 15:11:32 +0200 Subject: [PATCH 56/98] fix(messaging): repair stale usings left by rabbitmq merge (build was broken) --- .../Extensions/MessagingRegistrationExtensions.cs | 1 + .../SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs | 1 + .../Messaging/RabbitMq/RabbitMqQueueProviderTests.cs | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs index a07cb99c..5727be4b 100644 --- a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs @@ -2,6 +2,7 @@ using SquidStd.Core.Interfaces.Metrics; using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Abstractions.Services; using SquidStd.Messaging.Services; namespace SquidStd.Messaging.Extensions; diff --git a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs index 20be409e..0b036357 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs @@ -1,4 +1,5 @@ using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Data.Config; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs index 5e175f82..2e57aa9d 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs @@ -1,6 +1,8 @@ using System.Text; using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.RabbitMq; +using SquidStd.Messaging.RabbitMq.Data.Config; namespace SquidStd.Tests.Messaging.RabbitMq; From 8a5327206ce0b95b0b743e1ac16d2ea40d2c68c9 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:09:52 +0200 Subject: [PATCH 57/98] feat(scheduling): add Cronos dependency, ICronScheduler and config/DTO surface --- .../Data/Scheduling/CronJobInfo.cs | 28 +++++++++++++++++ .../Data/Timing/TimerWheelPumpConfig.cs | 12 +++++++ .../Interfaces/Scheduling/ICronScheduler.cs | 31 +++++++++++++++++++ .../SquidStd.Services.Core.csproj | 13 ++++---- 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs create mode 100644 src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs create mode 100644 src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs diff --git a/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs b/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs new file mode 100644 index 00000000..860db3fd --- /dev/null +++ b/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Core.Data.Scheduling; + +/// +/// Immutable snapshot describing a registered cron job. +/// +public sealed class CronJobInfo +{ + /// Gets the unique job id. + public required string JobId { get; init; } + + /// Gets the logical job name. + public required string Name { get; init; } + + /// Gets the raw cron expression. + public required string CronExpression { get; init; } + + /// Gets the next planned occurrence in UTC, or null when none. + public DateTime? NextOccurrenceUtc { get; init; } + + /// Gets a value indicating whether a run is currently in progress. + public bool IsRunning { get; init; } + + /// Gets the UTC time of the last successful run, or null. + public DateTime? LastRunUtc { get; init; } + + /// Gets the number of successful runs so far. + public long RunCount { get; init; } +} diff --git a/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs b/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs new file mode 100644 index 00000000..c106b8f8 --- /dev/null +++ b/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs @@ -0,0 +1,12 @@ +namespace SquidStd.Core.Data.Timing; + +/// +/// Configuration for the timer wheel pump service. +/// +public sealed class TimerWheelPumpConfig +{ + /// + /// Gets or sets how often the pump advances the timer wheel. + /// + public TimeSpan PumpInterval { get; set; } = TimeSpan.FromMilliseconds(250); +} diff --git a/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs new file mode 100644 index 00000000..3d43bc23 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs @@ -0,0 +1,31 @@ +using SquidStd.Core.Data.Scheduling; + +namespace SquidStd.Core.Interfaces.Scheduling; + +/// +/// Schedules asynchronous jobs on standard 5-field cron expressions (UTC). +/// +public interface ICronScheduler +{ + /// + /// Registers a cron job. + /// + /// Logical job name. + /// Standard 5-field cron expression, evaluated in UTC. + /// Asynchronous work invoked on each occurrence. + /// The unique job id. + string Schedule(string name, string cronExpression, Func handler); + + /// Removes a job by id. + /// The job id returned by . + /// true when a job was removed; otherwise false. + bool Unschedule(string jobId); + + /// Removes all jobs with the given name. + /// The job name. + /// The number of removed jobs. + int UnscheduleByName(string name); + + /// Gets a snapshot of all registered jobs. + IReadOnlyCollection Jobs { get; } +} diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index 5d12649d..60a1f51f 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -8,15 +8,16 @@ - - + + - - - - + + + + + From f9bdafe9bc285f690a3ec8d86c0033693e9c5a64 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:10:43 +0200 Subject: [PATCH 58/98] test(scheduling): add FakeTimerService and ManualJobSystem test doubles --- .../Support/FakeTimerService.cs | 71 +++++++++++++++++++ .../SquidStd.Tests/Support/ManualJobSystem.cs | 55 ++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/SquidStd.Tests/Support/FakeTimerService.cs create mode 100644 tests/SquidStd.Tests/Support/ManualJobSystem.cs diff --git a/tests/SquidStd.Tests/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Support/FakeTimerService.cs new file mode 100644 index 00000000..be9668a5 --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeTimerService.cs @@ -0,0 +1,71 @@ +using SquidStd.Core.Interfaces.Timing; + +namespace SquidStd.Tests.Support; + +/// +/// In-memory for tests. Timers do not fire on their own: +/// call to invoke and clear the currently-registered timers +/// (one-shot semantics). only records that a pump ran. +/// +public sealed class FakeTimerService : ITimerService +{ + private readonly Dictionary _timers = new(StringComparer.Ordinal); + + public int Count => _timers.Count; + + public int TickUpdates { get; private set; } + + public ManualResetEventSlim Pumped { get; } = new(false); + + public string RegisterTimer(string name, TimeSpan interval, Action callback, TimeSpan? delay = null, bool repeat = false) + { + var id = Guid.NewGuid().ToString("N"); + _timers[id] = (name, callback); + + return id; + } + + public void UnregisterAllTimers() + => _timers.Clear(); + + public bool UnregisterTimer(string timerId) + => _timers.Remove(timerId); + + public int UnregisterTimersByName(string name) + { + var ids = _timers.Where(kv => kv.Value.Name == name).Select(kv => kv.Key).ToArray(); + + foreach (var id in ids) + { + _timers.Remove(id); + } + + return ids.Length; + } + + public int UpdateTicksDelta(long timestampMilliseconds) + { + TickUpdates++; + Pumped.Set(); + + return 0; + } + + /// Invokes and removes every currently-registered timer; returns how many fired. + public int FireDue() + { + var snapshot = _timers.ToArray(); + + foreach (var kv in snapshot) + { + _timers.Remove(kv.Key); + } + + foreach (var kv in snapshot) + { + kv.Value.Callback(); + } + + return snapshot.Length; + } +} diff --git a/tests/SquidStd.Tests/Support/ManualJobSystem.cs b/tests/SquidStd.Tests/Support/ManualJobSystem.cs new file mode 100644 index 00000000..866281ab --- /dev/null +++ b/tests/SquidStd.Tests/Support/ManualJobSystem.cs @@ -0,0 +1,55 @@ +using SquidStd.Core.Interfaces.Jobs; + +namespace SquidStd.Tests.Support; + +/// +/// Single-threaded for tests: +/// queues the work; call to execute it. This keeps overlap and +/// rescheduling tests fully deterministic. +/// +public sealed class ManualJobSystem : IJobSystem +{ + private readonly List _pending = new(); + + public int WorkerCount => 1; + + public int PendingCount => _pending.Count; + + public int ActiveCount => 0; + + public long CompletedCount { get; private set; } + + public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) + { + _pending.Add(work); + + return Task.CompletedTask; + } + + public Task ScheduleAsync(Func work, CancellationToken cancellationToken = default) + { + var result = work(); + CompletedCount++; + + return Task.FromResult(result); + } + + /// Runs and clears all queued work; returns how many items ran. + public int RunAll() + { + var snapshot = _pending.ToArray(); + _pending.Clear(); + + foreach (var action in snapshot) + { + action(); + CompletedCount++; + } + + return snapshot.Length; + } + + public void Dispose() + { + } +} From d43c53eda93f0714197c68aebaa31293681726d3 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:12:00 +0200 Subject: [PATCH 59/98] feat(scheduling): add TimerWheelPumpService to advance the wheel --- .../Scheduling/TimerWheelPumpService.cs | 102 ++++++++++++++++++ .../Scheduling/TimerWheelPumpServiceTests.cs | 32 ++++++ 2 files changed, 134 insertions(+) create mode 100644 src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs create mode 100644 tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs diff --git a/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs new file mode 100644 index 00000000..1554e3e9 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Timing; +using SquidStd.Core.Interfaces.Timing; + +namespace SquidStd.Services.Core.Services.Scheduling; + +/// +/// Periodically advances the timer wheel so that wheel-backed timers fire in a normal +/// (non-game-loop) application. Drives on a +/// background loop. +/// +public sealed class TimerWheelPumpService : ISquidStdService, IDisposable +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ITimerService _timer; + private readonly TimeSpan _pumpInterval; + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; + private int _disposed; + + public TimerWheelPumpService(ITimerService timer, TimerWheelPumpConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + if (config.PumpInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(config), "PumpInterval must be positive."); + } + + _timer = timer; + _pumpInterval = config.PumpInterval; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + _loop = Task.Run(() => PumpLoopAsync(_cts.Token), CancellationToken.None); + + return ValueTask.CompletedTask; + } + + /// + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + if (_loop is null) + { + return; + } + + await _cts.CancelAsync(); + + try + { + await _loop; + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + + _loop = null; + } + + private async Task PumpLoopAsync(CancellationToken token) + { + var clock = Stopwatch.StartNew(); + using var ticker = new PeriodicTimer(_pumpInterval); + + try + { + while (await ticker.WaitForNextTickAsync(token)) + { + try + { + _timer.UpdateTicksDelta(clock.ElapsedMilliseconds); + } + catch (Exception ex) + { + _logger.Error(ex, "Timer wheel pump tick failed"); + } + } + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs new file mode 100644 index 00000000..c5eb6cba --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs @@ -0,0 +1,32 @@ +using SquidStd.Core.Data.Timing; +using SquidStd.Services.Core.Services.Scheduling; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Services.Core.Scheduling; + +public class TimerWheelPumpServiceTests +{ + [Fact] + public void Ctor_NonPositiveInterval_Throws() + { + Assert.Throws( + () => new TimerWheelPumpService(new FakeTimerService(), new TimerWheelPumpConfig { PumpInterval = TimeSpan.Zero }) + ); + } + + [Fact] + public async Task Pump_AdvancesTheWheel() + { + var timer = new FakeTimerService(); + var pump = new TimerWheelPumpService(timer, new TimerWheelPumpConfig { PumpInterval = TimeSpan.FromMilliseconds(20) }); + + await pump.StartAsync(); + + Assert.True(timer.Pumped.Wait(TimeSpan.FromSeconds(2))); + + await pump.StopAsync(); + pump.Dispose(); + + Assert.True(timer.TickUpdates >= 1); + } +} From 41e8d070d185b81c2e3434931105addd7f133e84 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:13:46 +0200 Subject: [PATCH 60/98] feat(scheduling): add CronSchedulerService with one-shot rescheduling --- .../Services/Internal/CronJobEntry.cs | 21 ++ .../Scheduling/CronSchedulerService.cs | 206 ++++++++++++++++++ .../Scheduling/CronSchedulerServiceTests.cs | 57 +++++ 3 files changed, 284 insertions(+) create mode 100644 src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs create mode 100644 src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs create mode 100644 tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs diff --git a/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs b/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs new file mode 100644 index 00000000..4cbd953d --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs @@ -0,0 +1,21 @@ +using Cronos; + +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// Internal mutable state for a registered cron job. +/// +internal sealed class CronJobEntry +{ + public required string JobId { get; init; } + public required string Name { get; init; } + public required string CronText { get; init; } + public required CronExpression Expression { get; init; } + public required Func Handler { get; init; } + + public string? TimerId; + public DateTime? NextOccurrenceUtc; + public DateTime? LastRunUtc; + public int Running; + public long RunCount; +} diff --git a/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs new file mode 100644 index 00000000..6b404fb1 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs @@ -0,0 +1,206 @@ +using System.Collections.Concurrent; +using Cronos; +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Scheduling; +using SquidStd.Core.Interfaces.Jobs; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Services.Core.Services.Internal; + +namespace SquidStd.Services.Core.Services.Scheduling; + +/// +/// Cron scheduler built on the timer wheel: each job is a one-shot, self-rescheduling +/// timer. On fire, the handler is dispatched through ; an occurrence +/// is skipped when the previous run of the same job is still in flight. +/// +public sealed class CronSchedulerService : ICronScheduler, ISquidStdService, IDisposable +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ITimerService _timer; + private readonly IJobSystem _jobs; + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _cts = new(); + private int _disposed; + + public CronSchedulerService(ITimerService timer, IJobSystem jobs) + { + _timer = timer; + _jobs = jobs; + } + + /// + public IReadOnlyCollection Jobs + => _entries.Values + .Select( + entry => new CronJobInfo + { + JobId = entry.JobId, + Name = entry.Name, + CronExpression = entry.CronText, + NextOccurrenceUtc = entry.NextOccurrenceUtc, + IsRunning = Volatile.Read(ref entry.Running) == 1, + LastRunUtc = entry.LastRunUtc, + RunCount = Interlocked.Read(ref entry.RunCount) + } + ) + .ToArray(); + + /// + public string Schedule(string name, string cronExpression, Func handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(cronExpression); + ArgumentNullException.ThrowIfNull(handler); + + var expression = CronExpression.Parse(cronExpression); + + var entry = new CronJobEntry + { + JobId = Guid.NewGuid().ToString("N"), + Name = name, + CronText = cronExpression, + Expression = expression, + Handler = handler + }; + + _entries[entry.JobId] = entry; + ScheduleNext(entry); + + return entry.JobId; + } + + /// + public bool Unschedule(string jobId) + { + if (!_entries.TryRemove(jobId, out var entry)) + { + return false; + } + + if (entry.TimerId is not null) + { + _timer.UnregisterTimer(entry.TimerId); + } + + return true; + } + + /// + public int UnscheduleByName(string name) + { + var ids = _entries.Values.Where(entry => entry.Name == name).Select(entry => entry.JobId).ToArray(); + var removed = 0; + + foreach (var id in ids) + { + if (Unschedule(id)) + { + removed++; + } + } + + return removed; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + _cts.Cancel(); + + foreach (var entry in _entries.Values) + { + if (entry.TimerId is not null) + { + _timer.UnregisterTimer(entry.TimerId); + } + } + + _entries.Clear(); + + return ValueTask.CompletedTask; + } + + private void ScheduleNext(CronJobEntry entry) + { + var now = DateTime.UtcNow; + var next = entry.Expression.GetNextOccurrence(now); + + if (next is null) + { + _logger.Information("Cron job '{Name}' ({JobId}) has no further occurrences", entry.Name, entry.JobId); + entry.TimerId = null; + entry.NextOccurrenceUtc = null; + + return; + } + + var delay = next.Value - now; + + if (delay <= TimeSpan.Zero) + { + delay = TimeSpan.FromMilliseconds(1); + } + + entry.NextOccurrenceUtc = next.Value; + entry.TimerId = _timer.RegisterTimer(entry.Name, delay, () => OnTimer(entry)); + } + + private void OnTimer(CronJobEntry entry) + { + if (!_entries.ContainsKey(entry.JobId)) + { + return; + } + + if (Interlocked.CompareExchange(ref entry.Running, 1, 0) != 0) + { + _logger.Warning( + "Cron job '{Name}' ({JobId}) skipped: previous run still in progress", + entry.Name, + entry.JobId + ); + } + else + { + _ = _jobs.ScheduleAsync(() => RunJob(entry)); + } + + ScheduleNext(entry); + } + + private void RunJob(CronJobEntry entry) + { + try + { + entry.Handler(_cts.Token).GetAwaiter().GetResult(); + entry.LastRunUtc = DateTime.UtcNow; + Interlocked.Increment(ref entry.RunCount); + } + catch (Exception ex) + { + _logger.Error(ex, "Cron job '{Name}' ({JobId}) failed", entry.Name, entry.JobId); + } + finally + { + Interlocked.Exchange(ref entry.Running, 0); + } + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs new file mode 100644 index 00000000..223ff895 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs @@ -0,0 +1,57 @@ +using Cronos; +using SquidStd.Services.Core.Services.Scheduling; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Services.Core.Scheduling; + +public class CronSchedulerServiceTests +{ + [Fact] + public void Fire_RunsHandler_AndReschedules() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + Assert.Equal(1, timer.Count); // one-shot registered + + timer.FireDue(); // fires -> job queued + rescheduled + Assert.Equal(1, jobs.RunAll()); + Assert.Equal(1, count); + Assert.Equal(1, timer.Count); // rescheduled + + timer.FireDue(); + jobs.RunAll(); + Assert.Equal(2, count); + } + + [Fact] + public void Jobs_ExposesRegisteredJob() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + + var id = scheduler.Schedule("snap", "* * * * *", _ => Task.CompletedTask); + + var info = Assert.Single(scheduler.Jobs); + Assert.Equal(id, info.JobId); + Assert.Equal("snap", info.Name); + Assert.Equal("* * * * *", info.CronExpression); + Assert.NotNull(info.NextOccurrenceUtc); + Assert.False(info.IsRunning); + Assert.Equal(0, info.RunCount); + } + + [Fact] + public void Schedule_InvalidCron_Throws() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + + Assert.Throws(() => { scheduler.Schedule("bad", "not a cron", _ => Task.CompletedTask); }); + } +} From a8c05a9e700c96b44c58d89fd20335383aae38ad Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:14:45 +0200 Subject: [PATCH 61/98] test(scheduling): cover overlap skip, unschedule and handler-exception resilience --- .../Scheduling/CronSchedulerServiceTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs index 223ff895..4a1f68b4 100644 --- a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs @@ -54,4 +54,70 @@ public void Schedule_InvalidCron_Throws() Assert.Throws(() => { scheduler.Schedule("bad", "not a cron", _ => Task.CompletedTask); }); } + + [Fact] + public void Fire_WhileRunning_SkipsOverlappingOccurrence() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + timer.FireDue(); // running flag set, job queued (not yet run) + timer.FireDue(); // still running -> occurrence skipped, no second job queued + + Assert.Equal(1, jobs.PendingCount); + jobs.RunAll(); + Assert.Equal(1, count); + } + + [Fact] + public void Unschedule_StopsFutureFirings() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + var id = scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + Assert.True(scheduler.Unschedule(id)); + Assert.Equal(0, timer.Count); + Assert.Equal(0, timer.FireDue()); + jobs.RunAll(); + Assert.Equal(0, count); + Assert.Empty(scheduler.Jobs); + } + + [Fact] + public void UnscheduleByName_RemovesAllMatching() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + scheduler.Schedule("dup", "* * * * *", _ => Task.CompletedTask); + scheduler.Schedule("dup", "* * * * *", _ => Task.CompletedTask); + scheduler.Schedule("other", "* * * * *", _ => Task.CompletedTask); + + Assert.Equal(2, scheduler.UnscheduleByName("dup")); + Assert.Single(scheduler.Jobs); + } + + [Fact] + public void Fire_HandlerThrows_IsLogged_AndKeepsRescheduling() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + scheduler.Schedule("boom", "* * * * *", _ => throw new InvalidOperationException("boom")); + + timer.FireDue(); + jobs.RunAll(); // handler throws, swallowed + Assert.Equal(1, timer.Count); // still rescheduled + + timer.FireDue(); + jobs.RunAll(); + Assert.Equal(1, timer.Count); // still alive + Assert.Equal(0, Assert.Single(scheduler.Jobs).RunCount); // failures do not count as runs + } } From aa7574c87c4a45d7ffa8de03aad869fb3edee704 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:15:21 +0200 Subject: [PATCH 62/98] test(scheduling): integration test driving the real timer wheel --- .../Scheduling/CronSchedulerServiceTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs index 4a1f68b4..b16a19c1 100644 --- a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs @@ -120,4 +120,26 @@ public void Fire_HandlerThrows_IsLogged_AndKeepsRescheduling() Assert.Equal(1, timer.Count); // still alive Assert.Equal(0, Assert.Single(scheduler.Jobs).RunCount); // failures do not count as runs } + + [Fact] + public void RealTimerWheel_FiresJob_WhenAdvancedPastAMinute() + { + var timer = new SquidStd.Services.Core.Services.TimerWheelService( + new SquidStd.Core.Data.Timing.TimerWheelConfig + { + TickDuration = TimeSpan.FromMilliseconds(8), + WheelSize = 512 + } + ); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + timer.UpdateTicksDelta(0); // baseline + timer.UpdateTicksDelta(61_000); // advance just over one minute + + Assert.True(jobs.RunAll() >= 1); + Assert.True(count >= 1); + } } From 06dbfac532df893a64b6a203e5cb76a5fbc647a1 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:16:42 +0200 Subject: [PATCH 63/98] feat(scheduling): add RegisterSchedulerServices DI extension --- .../RegisterSchedulerServicesExtension.cs | 32 +++++++++++++++++++ .../CronSchedulerRegistrationTests.cs | 30 +++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs create mode 100644 tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerRegistrationTests.cs diff --git a/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs new file mode 100644 index 00000000..dbed6903 --- /dev/null +++ b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs @@ -0,0 +1,32 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Data.Timing; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Services.Core.Services.Scheduling; + +namespace SquidStd.Services.Core.Extensions; + +/// +/// Extension methods for registering the SquidStd cron scheduler. +/// +public static class RegisterSchedulerServicesExtension +{ + /// Container that receives the scheduler registrations. + extension(IContainer container) + { + /// + /// Registers the timer wheel pump and the cron scheduler. Must be called after + /// RegisterCoreServices so that ITimerService and IJobSystem exist. + /// + /// The same container for chaining. + public IContainer RegisterSchedulerServices() + { + container.RegisterConfigSection("timerWheelPump", static () => new TimerWheelPumpConfig(), -90); + container.RegisterStdService(-1); + container.RegisterStdService(-1); + + return container; + } + } +} diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerRegistrationTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerRegistrationTests.cs new file mode 100644 index 00000000..0295e007 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerRegistrationTests.cs @@ -0,0 +1,30 @@ +using DryIoc; +using SquidStd.Core.Data.Jobs; +using SquidStd.Core.Data.Timing; +using SquidStd.Core.Interfaces.Jobs; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Scheduling; + +namespace SquidStd.Tests.Services.Core.Scheduling; + +public class CronSchedulerRegistrationTests +{ + [Fact] + public void RegisterSchedulerServices_ResolvesSchedulerAndPump() + { + var container = new Container(); + container.RegisterInstance(new TimerWheelConfig()); + container.RegisterInstance(new TimerWheelPumpConfig()); + container.RegisterInstance(new JobsConfig { WorkerThreadCount = 1 }); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + container.RegisterSchedulerServices(); + + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + } +} From 35596483d04bf1409f2c77660a33fe1f2b392b9f Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:21:41 +0200 Subject: [PATCH 64/98] refactor(messaging-rabbitmq): move provider and registration into Services/Extensions buckets --- .../RabbitMqMessagingRegistrationExtensions.cs | 4 +++- .../{ => Services}/RabbitMqQueueProvider.cs | 2 +- .../Messaging/RabbitMq/RabbitMqQueueProviderTests.cs | 3 +-- 3 files changed, 5 insertions(+), 4 deletions(-) rename src/SquidStd.Messaging.RabbitMq/{ => Extensions}/RabbitMqMessagingRegistrationExtensions.cs (96%) rename src/SquidStd.Messaging.RabbitMq/{ => Services}/RabbitMqQueueProvider.cs (99%) diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs similarity index 96% rename from src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs rename to src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs index c2b65951..e6b16408 100644 --- a/src/SquidStd.Messaging.RabbitMq/RabbitMqMessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs @@ -5,7 +5,9 @@ using SquidStd.Messaging.Abstractions.Services; using SquidStd.Messaging.RabbitMq.Data.Config; -namespace SquidStd.Messaging.RabbitMq; +using SquidStd.Messaging.RabbitMq.Services; + +namespace SquidStd.Messaging.RabbitMq.Extensions; /// /// DryIoc registration helpers for the RabbitMQ messaging provider. diff --git a/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs similarity index 99% rename from src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs rename to src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs index bd0a19b7..aa2d2127 100644 --- a/src/SquidStd.Messaging.RabbitMq/RabbitMqQueueProvider.cs +++ b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs @@ -7,7 +7,7 @@ using SquidStd.Messaging.Abstractions.Services; using SquidStd.Messaging.RabbitMq.Data.Config; -namespace SquidStd.Messaging.RabbitMq; +namespace SquidStd.Messaging.RabbitMq.Services; /// /// RabbitMQ : named queues map to quorum queues with a delivery limit diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs index 2e57aa9d..3876c036 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs @@ -1,8 +1,7 @@ using System.Text; -using SquidStd.Messaging.Abstractions; using SquidStd.Messaging.Abstractions.Data.Config; -using SquidStd.Messaging.RabbitMq; using SquidStd.Messaging.RabbitMq.Data.Config; +using SquidStd.Messaging.RabbitMq.Services; namespace SquidStd.Tests.Messaging.RabbitMq; From 3c6f66de432bacc381d1ed36d88d76db9a2ec6a3 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:31:18 +0200 Subject: [PATCH 65/98] build: opt projects with a README.md into PackageReadmeFile --- Directory.Build.props | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 6237807f..b69d2be4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -72,4 +72,12 @@ + + + README.md + + + + + From fde57c14fc756a76cac3ab4f1fa85642ee2ba9b9 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:50 +0200 Subject: [PATCH 66/98] docs(core): add NuGet package README --- src/SquidStd.Core/README.md | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/SquidStd.Core/README.md diff --git a/src/SquidStd.Core/README.md b/src/SquidStd.Core/README.md new file mode 100644 index 00000000..c750c50d --- /dev/null +++ b/src/SquidStd.Core/README.md @@ -0,0 +1,60 @@ +

+ SquidStd +

+ +

SquidStd.Core

+ +

+ NuGet + Downloads + license +

+ +Foundational contracts and utilities for the SquidStd stack. It defines the core service interfaces +(configuration, event bus, jobs, timing, metrics, storage) and ships dependency-free helpers — YAML/JSON +serialization, a Serilog event sink, and string/environment/directory extensions — that the other +SquidStd packages build on. + +## Install + +```bash +dotnet add package SquidStd.Core +``` + +## Features + +- Configuration contracts: `IConfigEntry` (a YAML section) and `IConfigManagerService`. +- In-process messaging: `IEventBus` with `ISyncEventListener` / `IAsyncEventListener` over `IEvent`. +- Background work & timing: `IJobSystem`, `ITimerService`, `IMainThreadDispatcher`. +- Metrics & storage: `IMetricProvider`, `IStorageService`, `IObjectStorageService`, secrets contracts. +- Utilities: `YamlUtils`, `JsonUtils`, a Serilog `EventSink`, and string/env/directory extensions. +- Shared domain enums under `Types` (e.g. `LogLevelType`, `PlatformType`). + +## Usage + +```csharp +using SquidStd.Core.Extensions.Env; +using SquidStd.Core.Yaml; + +// Expand "$VAR" tokens against the environment (unknown vars are left untouched). +var path = "$HOME/squidstd/data".ReplaceEnv(); + +// Serialize / deserialize YAML. +var yaml = YamlUtils.Serialize(new { name = "squid", port = 9000 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IConfigEntry` | A registrable YAML configuration section. | +| `IConfigManagerService` | Loads YAML config and exposes typed sections. | +| `IEventBus` | Publish/subscribe in-process event bus. | +| `IJobSystem` | Background job scheduling/execution. | +| `ITimerService` | Timer-wheel based scheduling. | +| `IMetricProvider` | Source of metric samples for collection. | +| `IStorageService` | File/object storage abstraction. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From 303abfba8d7da9480ed6ec01d6875049390e2e94 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:50 +0200 Subject: [PATCH 67/98] docs(abstractions): add NuGet package README --- src/SquidStd.Abstractions/README.md | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/SquidStd.Abstractions/README.md diff --git a/src/SquidStd.Abstractions/README.md b/src/SquidStd.Abstractions/README.md new file mode 100644 index 00000000..000247c5 --- /dev/null +++ b/src/SquidStd.Abstractions/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Abstractions

+ +

+ NuGet + Downloads + license +

+ +DryIoc-based dependency-injection plumbing for SquidStd. It defines the `ISquidStdService` lifecycle +contract and the container extensions used to register services and configuration sections in a uniform, +discoverable way (tracked through ordered registration lists). + +## Install + +```bash +dotnet add package SquidStd.Abstractions +``` + +## Features + +- `ISquidStdService` — a `StartAsync`/`StopAsync` lifecycle contract for managed services. +- `RegisterStdService()` — register a singleton service and record it in the + ordered service list (with optional priority). +- `RegisterConfigSection(sectionName)` — register a YAML config section for the config manager. +- `AddToRegisterTypedList(...)` — maintain ordered registration lists in the container. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; + +var container = new Container(); + +container.RegisterStdService(); +container.RegisterConfigSection("my"); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `ISquidStdService` | Async start/stop lifecycle for managed services. | +| `RegisterStdServiceExtension` | `RegisterStdService<,>` container extension. | +| `RegisterConfigSectionExtension` | `RegisterConfigSection<>` container extension. | +| `ServiceRegistrationData` | Ordered service registration record. | +| `ConfigRegistrationData` | Config section registration record. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From c3f4c1ca946b80cab5aea4cf73f4cef380446eeb Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:50 +0200 Subject: [PATCH 68/98] docs(services-core): add NuGet package README --- src/SquidStd.Services.Core/README.md | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/SquidStd.Services.Core/README.md diff --git a/src/SquidStd.Services.Core/README.md b/src/SquidStd.Services.Core/README.md new file mode 100644 index 00000000..f0a16681 --- /dev/null +++ b/src/SquidStd.Services.Core/README.md @@ -0,0 +1,59 @@ +

+ SquidStd +

+ +

SquidStd.Services.Core

+ +

+ NuGet + Downloads + license +

+ +Concrete implementations of the SquidStd.Core contracts, wired for DryIoc. A single +`RegisterCoreServices()` call brings up the configuration manager, event bus, job system, timer wheel, +main-thread dispatcher, metrics collection, storage, and secrets services. + +## Install + +```bash +dotnet add package SquidStd.Services.Core +``` + +## Features + +- One-line bootstrap: `container.RegisterCoreServices()` registers the full default service set. +- `ConfigManagerService` — loads/saves YAML config sections and substitutes `$ENV_VAR` tokens. +- `EventBusService` — in-process publish/subscribe over `IEvent`. +- `JobSystemService` — background job execution; `TimerWheelService` + cron scheduling for timed work. +- `MainThreadDispatcherService` — marshal work back onto a main thread. +- `MetricsCollectionService` — aggregates `IMetricProvider` samples. +- File storage, object storage, and AES-GCM-protected secret store. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Services.Core.Extensions; + +var container = new Container(); + +// Registers config manager + event bus + jobs + timer wheel + dispatcher + metrics + storage + secrets. +container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory()); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RegisterDefaultServicesExtensions` | `RegisterCoreServices()` / `RegisterConfigManagerService()` entry points. | +| `ConfigManagerService` | YAML config load/save with env-var substitution. | +| `EventBusService` | In-process event bus implementation. | +| `JobSystemService` | Background job execution. | +| `TimerWheelService` | Timer-wheel scheduling. | +| `MainThreadDispatcherService` | Main-thread work dispatch. | +| `MetricsCollectionService` | Metric sample aggregation. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From 7e117f66649472a9fcb4052f8e92dfdf17f10cc7 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:50 +0200 Subject: [PATCH 69/98] docs(network): add NuGet package README --- src/SquidStd.Network/README.md | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/SquidStd.Network/README.md diff --git a/src/SquidStd.Network/README.md b/src/SquidStd.Network/README.md new file mode 100644 index 00000000..436c4062 --- /dev/null +++ b/src/SquidStd.Network/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Network

+ +

+ NuGet + Downloads + license +

+ +Networking primitives for SquidStd: TCP and UDP servers and clients with per-connection sessions, a +pluggable framing + middleware pipeline, span-based binary readers/writers, and a circular buffer — +designed for low-allocation, high-throughput byte processing. + +## Install + +```bash +dotnet add package SquidStd.Network +``` + +## Features + +- TCP server/client (`SquidTcpServer`, `SquidStdTcpClient`) with optional TLS. +- UDP server/client (`SquidStdUdpServer`, `SquidStdUdpClient`). +- Session management (`ISessionManager`) with typed per-connection state. +- Composable framing (`INetFramer`) and middleware pipeline (`INetMiddleware`). +- Zero-copy binary I/O via `SpanReader` / `SpanWriter` and a reusable `CircularBuffer`. + +## Usage + +```csharp +using System.Net; +using SquidStd.Network.Server; + +await using var server = new SquidTcpServer(new IPEndPoint(IPAddress.Any, 9000)); +await server.StartAsync(CancellationToken.None); +// ... server.IsRunning, server.Port ... +await server.StopAsync(CancellationToken.None); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `INetworkServer` | TCP/UDP server contract (`StartAsync`/`StopAsync`). | +| `INetworkConnection` | A single client connection. | +| `ISessionManager` | Tracks sessions and their typed state. | +| `INetFramer` | Splits the byte stream into messages. | +| `INetMiddleware` | Pipeline stage over inbound/outbound data. | +| `SpanReader` / `SpanWriter` | Allocation-free binary read/write. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From 6c44c56ec2be94b951a4717a76018d9e64a328f1 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:51 +0200 Subject: [PATCH 70/98] docs(plugin-abstractions): add NuGet package README --- src/SquidStd.Plugin.Abstractions/README.md | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/SquidStd.Plugin.Abstractions/README.md diff --git a/src/SquidStd.Plugin.Abstractions/README.md b/src/SquidStd.Plugin.Abstractions/README.md new file mode 100644 index 00000000..838afec0 --- /dev/null +++ b/src/SquidStd.Plugin.Abstractions/README.md @@ -0,0 +1,63 @@ +

+ SquidStd +

+ +

SquidStd.Plugin.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Contracts for building SquidStd plugins. A plugin declares its identity through `PluginMetadata` and +registers its services into the host's DryIoc container via `Configure`, receiving a `PluginContext` +with shared boot data. + +## Install + +```bash +dotnet add package SquidStd.Plugin.Abstractions +``` + +## Features + +- `ISquidStdPlugin` — the plugin entry point: `Metadata` + `Configure(IContainer, PluginContext)`. +- `PluginMetadata` — id, name, `Version`, author, optional description, and dependency declarations. +- `PluginContext` — a typed bag of boot data shared with the plugin (`GetData(key)`). + +## Usage + +```csharp +using DryIoc; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +public sealed class MyPlugin : ISquidStdPlugin +{ + public PluginMetadata Metadata { get; } = new() + { + Id = "com.example.myplugin", + Name = "My Plugin", + Version = new Version(1, 0, 0), + Author = "me" + }; + + public void Configure(IContainer container, PluginContext context) + { + // register the plugin's services / config sections here + } +} +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `ISquidStdPlugin` | Plugin entry point (`Metadata`, `Configure`). | +| `PluginMetadata` | Plugin identity and dependency declarations. | +| `PluginContext` | Shared boot data passed to the plugin. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From 1d0ea3991214bcdef903709ed06625fe1efc843f Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:51 +0200 Subject: [PATCH 71/98] docs(aspnetcore): add NuGet package README --- src/SquidStd.AspNetCore/README.md | 55 +++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/SquidStd.AspNetCore/README.md diff --git a/src/SquidStd.AspNetCore/README.md b/src/SquidStd.AspNetCore/README.md new file mode 100644 index 00000000..ff627d45 --- /dev/null +++ b/src/SquidStd.AspNetCore/README.md @@ -0,0 +1,55 @@ +

+ SquidStd +

+ +

SquidStd.AspNetCore

+ +

+ NuGet + Downloads + license +

+ +ASP.NET Core integration for SquidStd. A single `builder.UseSquidStd(...)` call wires the SquidStd +DryIoc container into the web host and registers a hosted service that starts and stops every +`ISquidStdService` alongside the application lifecycle. + +## Install + +```bash +dotnet add package SquidStd.AspNetCore +``` + +## Features + +- `WebApplicationBuilder.UseSquidStd(...)` — plug the SquidStd container and services into a web app. +- Configures DryIoc as the host's service-provider factory. +- Registers `SquidStdHostedService` to start/stop SquidStd services with the host. +- Optional `SquidStdOptions` configuration callback. + +## Usage + +```csharp +using SquidStd.AspNetCore.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.UseSquidStd(options => +{ + // configure SquidStd options here +}); + +var app = builder.Build(); +app.Run(); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `SquidStdAspNetCoreBuilderExtensions` | `UseSquidStd(...)` builder extension. | +| `SquidStdHostedService` | Hosted service bridging SquidStd service lifecycle to the host. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From 09b7a1b456d5ed6c4f9770201aeca33933fdba24 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:51 +0200 Subject: [PATCH 72/98] docs(database-abstractions): add NuGet package README --- src/SquidStd.Database.Abstractions/README.md | 63 ++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/SquidStd.Database.Abstractions/README.md diff --git a/src/SquidStd.Database.Abstractions/README.md b/src/SquidStd.Database.Abstractions/README.md new file mode 100644 index 00000000..20073769 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/README.md @@ -0,0 +1,63 @@ +

+ SquidStd +

+ +

SquidStd.Database.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Provider-agnostic data-access contracts for SquidStd. Entities derive from `BaseEntity` (a `Guid` id +plus UTC timestamps) and are accessed through the generic `IDataAccess` — full CRUD, bulk +operations, paging, and composable queries — without binding to any specific ORM. + +## Install + +```bash +dotnet add package SquidStd.Database.Abstractions +``` + +## Features + +- `IDataAccess` — `InsertAsync`, `GetByIdAsync`, `UpdateAsync`, `DeleteAsync`, `CountAsync`, + `ExistsAsync`, bulk insert/update/delete, `QueryAsync`, and `GetPagedAsync`. +- `BaseEntity` — `Guid Id` plus `DateTimeOffset Created` / `Updated` (UTC), set by the data layer. +- `PagedResultData` — items + `Page`, `PageSize`, `TotalCount`, `TotalPages`, `HasNext`, `HasPrevious`. +- `DatabaseConfig` — URI connection string + `AutoMigrate` flag. +- `DatabaseProviderType` — `Sqlite`, `Postgres`, `SqlServer`, `MySql`. + +## Usage + +```csharp +using SquidStd.Database.Abstractions.Data.Entities; +using SquidStd.Database.Abstractions.Interfaces.Data; + +public sealed class User : BaseEntity +{ + public string Name { get; set; } = string.Empty; +} + +// IDataAccess is resolved from DI (see the SquidStd.Database package). +public async Task ExampleAsync(IDataAccess users) +{ + await users.InsertAsync(new User { Name = "Ann" }); + var page = await users.GetPagedAsync(page: 1, pageSize: 20, orderBy: u => u.Name); +} +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IDataAccess` | CRUD + bulk + paged + composable query contract. | +| `BaseEntity` | Guid id + UTC created/updated base entity. | +| `PagedResultData` | Paginated result with metadata. | +| `DatabaseConfig` | Connection string + auto-migrate config section. | +| `DatabaseProviderType` | Supported provider enum. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From e87ad0150fdd3c7a7d494adbe4bbdc896808abaa Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:35:51 +0200 Subject: [PATCH 73/98] docs(database): add NuGet package README --- src/SquidStd.Database/README.md | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/SquidStd.Database/README.md diff --git a/src/SquidStd.Database/README.md b/src/SquidStd.Database/README.md new file mode 100644 index 00000000..412484cd --- /dev/null +++ b/src/SquidStd.Database/README.md @@ -0,0 +1,61 @@ +

+ SquidStd +

+ +

SquidStd.Database

+ +

+ NuGet + Downloads + license +

+ +FreeSql-backed implementation of the SquidStd.Database.Abstractions contracts. It owns a singleton +`IFreeSql`, exposes a generic `FreeSqlDataAccess` with transactional writes (rollback on +failure), bulk operations and paging, parses URI-style connection strings, and can auto-sync the schema +on startup. + +## Install + +```bash +dotnet add package SquidStd.Database +``` + +## Features + +- One-line registration: `container.RegisterDatabase()` (config section + service + open-generic `IDataAccess<>`). +- Providers via URI scheme: `sqlite://`, `postgres://`, `sqlserver://`, `mysql://`. +- `FreeSqlDataAccess` — CRUD, bulk insert/update/delete, `QueryAsync`, `GetPagedAsync`; writes + run inside a unit of work and roll back on error. Sets `Id`/`Created`/`Updated` automatically. +- Optional `AutoMigrate` (FreeSql `AutoSyncStructure`) to create/update tables on startup. +- ZLinq in-memory helpers for zero-allocation post-processing of materialized results. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Database.Abstractions.Interfaces.Data; +using SquidStd.Database.Extensions; + +var container = new Container(); +container.RegisterDatabase(); // reads the "database" config section + +// DatabaseConfig: ConnectionString = "postgres://user:pass@host:5432/app", AutoMigrate = true +var users = container.Resolve>(); +await users.InsertAsync(new User { Name = "Ann" }); +var page = await users.GetPagedAsync(page: 1, pageSize: 20, orderBy: u => u.Name); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RegisterDatabaseExtension` | `RegisterDatabase()` DI registration. | +| `DatabaseService` | Owns the singleton `IFreeSql`; builds it and (optionally) migrates. | +| `FreeSqlDataAccess` | FreeSql `IDataAccess` implementation. | +| `ConnectionStringParser` | URI → provider + native connection string. | +| `ZLinqResultExtensions` | Zero-alloc in-memory result helpers. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From 571c3c355a034f1417ca9880b3868774f1f09b3b Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:39:55 +0200 Subject: [PATCH 74/98] feat(packaging): publish Messaging, Messaging.Abstractions, Messaging.RabbitMq and Scripting.Lua with READMEs - add true to the four projects - add NuGet package README to each --- src/SquidStd.Messaging.Abstractions/README.md | 63 +++++++++++++++++++ .../SquidStd.Messaging.Abstractions.csproj | 1 + src/SquidStd.Messaging.RabbitMq/README.md | 54 ++++++++++++++++ .../SquidStd.Messaging.RabbitMq.csproj | 1 + src/SquidStd.Messaging/README.md | 54 ++++++++++++++++ .../SquidStd.Messaging.csproj | 1 + src/SquidStd.Scripting.Lua/README.md | 61 ++++++++++++++++++ .../SquidStd.Scripting.Lua.csproj | 1 + 8 files changed, 236 insertions(+) create mode 100644 src/SquidStd.Messaging.Abstractions/README.md create mode 100644 src/SquidStd.Messaging.RabbitMq/README.md create mode 100644 src/SquidStd.Messaging/README.md create mode 100644 src/SquidStd.Scripting.Lua/README.md diff --git a/src/SquidStd.Messaging.Abstractions/README.md b/src/SquidStd.Messaging.Abstractions/README.md new file mode 100644 index 00000000..fa11943d --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/README.md @@ -0,0 +1,63 @@ +

+ SquidStd +

+ +

SquidStd.Messaging.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Transport-agnostic messaging contracts for SquidStd. It defines the typed queue facade +(`IMessageQueue`), the low-level provider/serializer/metrics contracts, the message-listener interfaces, +and the shared `MessageQueue` facade plus default serializer and metrics. Pick a transport +implementation (in-memory or RabbitMQ) from a companion package. + +## Install + +```bash +dotnet add package SquidStd.Messaging.Abstractions +``` + +## Features + +- `IMessageQueue` — typed `PublishAsync` / `Subscribe` facade over named queues. +- `IQueueProvider` — the raw transport contract implemented per backend. +- `IMessageSerializer` (+ default `JsonMessageSerializer`) — payload (de)serialization. +- `IQueueMessageListener` / `IQueueMessageListenerAsync` — sync/async subscribers. +- `IMessagingMetrics` (+ `MessagingMetricsProvider`, `NoOpMessagingMetrics`) — delivery metrics. +- `MessagingOptions` and `MessagingConnectionString` — configuration and connection parsing. + +## Usage + +```csharp +using SquidStd.Messaging.Abstractions.Interfaces; + +public sealed class OrderCreated { public int Id { get; init; } } + +public sealed class OrderListener : IQueueMessageListener +{ + public void Handle(OrderCreated message) { /* ... */ } +} + +// Resolve IMessageQueue from a transport package (in-memory or RabbitMQ). +public async Task PublishAsync(IMessageQueue queue) + => await queue.PublishAsync("orders", new OrderCreated { Id = 1 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IMessageQueue` | Typed publish/subscribe facade. | +| `IQueueProvider` | Transport contract (per backend). | +| `IMessageSerializer` | Payload (de)serialization. | +| `IQueueMessageListener` | Subscriber callbacks. | +| `IMessagingMetrics` | Delivery metrics sink. | +| `MessagingOptions` | DLQ/retry/prefetch configuration. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj index e7e82ace..5f9c6603 100644 --- a/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj +++ b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj @@ -1,6 +1,7 @@ + true net10.0 enable enable diff --git a/src/SquidStd.Messaging.RabbitMq/README.md b/src/SquidStd.Messaging.RabbitMq/README.md new file mode 100644 index 00000000..27ee2b30 --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/README.md @@ -0,0 +1,54 @@ +

+ SquidStd +

+ +

SquidStd.Messaging.RabbitMq

+ +

+ NuGet + Downloads + license +

+ +RabbitMQ transport for SquidStd.Messaging. Implements `IQueueProvider` on top of the RabbitMQ client, +so the same `IMessageQueue` API publishes to and consumes from a real broker. Registered with a single +`AddRabbitMqMessaging(...)` call. + +## Install + +```bash +dotnet add package SquidStd.Messaging.RabbitMq +``` + +## Features + +- One-line registration: `container.AddRabbitMqMessaging(connectionString)` or with `RabbitMqOptions`. +- Broker-backed `IQueueProvider` reusing the shared `IMessageQueue` facade and serializer. +- Connection via AMQP URI or explicit host/port/vhost/credentials (`RabbitMqOptions`). +- Configurable consumer prefetch count. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.RabbitMq.Extensions; + +var container = new Container(); +container.AddRabbitMqMessaging("amqp://guest:guest@localhost:5672/"); + +var queue = container.Resolve(); +await queue.PublishAsync("orders", new { Id = 1 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RabbitMqMessagingRegistrationExtensions` | `AddRabbitMqMessaging(...)` registration. | +| `RabbitMqQueueProvider` | RabbitMQ-backed `IQueueProvider`. | +| `RabbitMqOptions` | Connection + prefetch configuration. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj index 79f2b1fa..400b64c3 100644 --- a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj +++ b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj @@ -1,6 +1,7 @@ + true net10.0 enable enable diff --git a/src/SquidStd.Messaging/README.md b/src/SquidStd.Messaging/README.md new file mode 100644 index 00000000..fbc4310b --- /dev/null +++ b/src/SquidStd.Messaging/README.md @@ -0,0 +1,54 @@ +

+ SquidStd +

+ +

SquidStd.Messaging

+ +

+ NuGet + Downloads + license +

+ +In-memory transport for SquidStd.Messaging. Provides a channel-backed `IQueueProvider` with per-queue +buffering, round-robin delivery to subscribers, retry/dead-letter handling, and metrics — registered +with a single `AddInMemoryMessaging()` call. Ideal for single-process apps, tests, and local dev. + +## Install + +```bash +dotnet add package SquidStd.Messaging +``` + +## Features + +- One-line registration: `container.AddInMemoryMessaging()` (facade, provider, serializer, metrics). +- Channel-based per-queue buffering with a dedicated consumer loop. +- Round-robin delivery across multiple subscribers of the same queue. +- Retry with configurable max attempts and dead-letter queues (`MessagingOptions`). +- Built-in delivery metrics via `MessagingMetricsProvider`. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Extensions; + +var container = new Container(); +container.AddInMemoryMessaging(); + +var queue = container.Resolve(); +await queue.PublishAsync("orders", new { Id = 1 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `MessagingRegistrationExtensions` | `AddInMemoryMessaging(...)` registration. | +| `InMemoryQueueProvider` | Channel-based in-memory `IQueueProvider`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Messaging/SquidStd.Messaging.csproj b/src/SquidStd.Messaging/SquidStd.Messaging.csproj index b7117b29..0d0fa10f 100644 --- a/src/SquidStd.Messaging/SquidStd.Messaging.csproj +++ b/src/SquidStd.Messaging/SquidStd.Messaging.csproj @@ -1,6 +1,7 @@ + true net10.0 enable enable diff --git a/src/SquidStd.Scripting.Lua/README.md b/src/SquidStd.Scripting.Lua/README.md new file mode 100644 index 00000000..4ada8e28 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/README.md @@ -0,0 +1,61 @@ +

+ SquidStd +

+ +

SquidStd.Scripting.Lua

+ +

+ NuGet + Downloads + license +

+ +Lua scripting for SquidStd. Hosts a Lua engine (`IScriptEngineService`) that exposes .NET methods to +scripts through attribute-decorated modules, bridges events, generates `.luarc` typings/docs, and +supports init scripts, constants, and callbacks. + +## Install + +```bash +dotnet add package SquidStd.Scripting.Lua +``` + +## Features + +- `IScriptEngineService` — load and run Lua scripts; register modules, constants, callbacks, init scripts. +- Attribute-based modules: mark a class `[ScriptModule]` and methods `[ScriptFunction]` to expose them. +- `container.RegisterScriptModule()` / `RegisterLuaUserData()` registration extensions. +- Event bridging to the SquidStd event bus (`ILuaEventBridge`). +- Built-in modules (logging, events, random) and `.luarc` documentation generation. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Extensions.Scripts; + +[ScriptModule("math2")] +public sealed class MathModule +{ + [ScriptFunction("add")] + public int Add(int a, int b) => a + b; +} + +var container = new Container(); +container.RegisterScriptModule(); +// Resolve IScriptEngineService to load and execute scripts that call math2.add(1, 2). +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IScriptEngineService` | Lua engine: load/run scripts, register modules/constants/callbacks. | +| `ILuaEventBridge` | Bridges Lua scripts to the event bus. | +| `ScriptModuleAttribute` / `ScriptFunctionAttribute` | Expose .NET classes/methods to Lua. | +| `AddScriptModuleExtension` | `RegisterScriptModule()` / `RegisterLuaUserData()`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj index d4f89a31..57b7c331 100644 --- a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj +++ b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj @@ -1,6 +1,7 @@  + true net10.0 enable enable From 89b9a1c662a1fcb6119bec59d5b0b4da734d8653 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:39:55 +0200 Subject: [PATCH 75/98] docs: add full packages index (12 packages) to root README --- README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0e97b202..a5da3cbf 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,20 @@ Short description goes here. ## Packages -| Package | Version | Downloads | -|---------|---------|-----------| -| `SquidStd.Abstractions` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Abstractions/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Abstractions.svg) | -| `SquidStd.AspNetCore` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.AspNetCore.svg)](https://www.nuget.org/packages/SquidStd.AspNetCore/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.AspNetCore.svg) | -| `SquidStd.Core` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Core.svg) | -| `SquidStd.Network` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Network.svg)](https://www.nuget.org/packages/SquidStd.Network/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Network.svg) | -| `SquidStd.Plugin.Abstractions` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Plugin.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Plugin.Abstractions.svg) | -| `SquidStd.Services.Core` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Services.Core.svg)](https://www.nuget.org/packages/SquidStd.Services.Core/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Services.Core.svg) | +| Package | Description | Links | +|---------|-------------|-------| +| `SquidStd.Core` | Foundational contracts & utilities (config, event bus, jobs, metrics, storage, YAML/JSON, Serilog sink). | [readme](src/SquidStd.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | +| `SquidStd.Abstractions` | DI registration plumbing (`ISquidStdService`, `RegisterStdService`, `RegisterConfigSection`). | [readme](src/SquidStd.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Abstractions/) | +| `SquidStd.Services.Core` | Concrete services: config, event bus, jobs, timer/cron scheduler, dispatcher, metrics, storage. | [readme](src/SquidStd.Services.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Services.Core.svg)](https://www.nuget.org/packages/SquidStd.Services.Core/) | +| `SquidStd.AspNetCore` | ASP.NET Core host integration for the SquidStd service stack. | [readme](src/SquidStd.AspNetCore/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.AspNetCore.svg)](https://www.nuget.org/packages/SquidStd.AspNetCore/) | +| `SquidStd.Network` | TCP/UDP servers & clients, sessions, framing/middleware pipeline, span readers/writers. | [readme](src/SquidStd.Network/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Network.svg)](https://www.nuget.org/packages/SquidStd.Network/) | +| `SquidStd.Plugin.Abstractions` | Plugin contracts (`ISquidStdPlugin`, metadata, context). | [readme](src/SquidStd.Plugin.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Plugin.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) | +| `SquidStd.Database.Abstractions` | Provider-agnostic data-access contracts (`IDataAccess`, `BaseEntity`, `PagedResultData`). | [readme](src/SquidStd.Database.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Database.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Database.Abstractions/) | +| `SquidStd.Database` | FreeSql-backed data access (CRUD/bulk/paging, URI connection strings, ZLinq helpers). | [readme](src/SquidStd.Database/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Database.svg)](https://www.nuget.org/packages/SquidStd.Database/) | +| `SquidStd.Messaging.Abstractions` | Messaging contracts (`IMessageQueue`, `IQueueProvider`, serializer/metrics, listeners). | [readme](src/SquidStd.Messaging.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Messaging.Abstractions/) | +| `SquidStd.Messaging` | In-memory messaging transport (`AddInMemoryMessaging`). | [readme](src/SquidStd.Messaging/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.svg)](https://www.nuget.org/packages/SquidStd.Messaging/) | +| `SquidStd.Messaging.RabbitMq` | RabbitMQ messaging transport (`AddRabbitMqMessaging`). | [readme](src/SquidStd.Messaging.RabbitMq/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.RabbitMq.svg)](https://www.nuget.org/packages/SquidStd.Messaging.RabbitMq/) | +| `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [readme](src/SquidStd.Scripting.Lua/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Scripting.Lua.svg)](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) | ## Build From 8bbcdebc309b0f8d23fc2c5b28645bfd23440dff Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:40:07 +0200 Subject: [PATCH 76/98] feat(core): add IDataSerializer/IDataDeserializer with JsonDataSerializer --- .../Serialization/IDataDeserializer.cs | 10 ++++++ .../Serialization/IDataSerializer.cs | 10 ++++++ src/SquidStd.Core/Json/JsonDataSerializer.cs | 31 +++++++++++++++++ .../Core/Json/JsonDataSerializerTests.cs | 33 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs create mode 100644 src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs create mode 100644 src/SquidStd.Core/Json/JsonDataSerializer.cs create mode 100644 tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs diff --git a/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs b/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs new file mode 100644 index 00000000..0c649821 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Core.Interfaces.Serialization; + +/// +/// Deserializes bytes to typed values. +/// +public interface IDataDeserializer +{ + /// Deserializes bytes to a value. + T Deserialize(ReadOnlyMemory data); +} diff --git a/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs b/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs new file mode 100644 index 00000000..6b4b140c --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Core.Interfaces.Serialization; + +/// +/// Serializes typed values to bytes. +/// +public interface IDataSerializer +{ + /// Serializes a value to bytes. + ReadOnlyMemory Serialize(T value); +} diff --git a/src/SquidStd.Core/Json/JsonDataSerializer.cs b/src/SquidStd.Core/Json/JsonDataSerializer.cs new file mode 100644 index 00000000..b06c8739 --- /dev/null +++ b/src/SquidStd.Core/Json/JsonDataSerializer.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using SquidStd.Core.Interfaces.Serialization; + +namespace SquidStd.Core.Json; + +/// +/// Default JSON data serializer based on Web defaults +/// (reflection-based, supports arbitrary types). Implements both +/// and . +/// +public sealed class JsonDataSerializer : IDataSerializer, IDataDeserializer +{ + private readonly JsonSerializerOptions _options; + + public JsonDataSerializer() + { + _options = new(JsonSerializerDefaults.Web); + } + + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed."), + RequiresDynamicCode("JSON serialization may require runtime code generation.")] + public ReadOnlyMemory Serialize(T value) + => JsonSerializer.SerializeToUtf8Bytes(value, _options); + + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed."), + RequiresDynamicCode("JSON deserialization may require runtime code generation.")] + public T Deserialize(ReadOnlyMemory data) + => JsonSerializer.Deserialize(data.Span, _options) ?? + throw new InvalidOperationException($"Deserialization returned null for type {typeof(T).Name}."); +} diff --git a/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs new file mode 100644 index 00000000..82587c95 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs @@ -0,0 +1,33 @@ +using SquidStd.Core.Json; + +namespace SquidStd.Tests.Core.Json; + +public class JsonDataSerializerTests +{ + private sealed class Sample + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + } + + [Fact] + public void RoundTrip_PreservesValue() + { + var serializer = new JsonDataSerializer(); + var bytes = serializer.Serialize(new Sample { Name = "abc", Count = 7 }); + + var result = serializer.Deserialize(bytes); + + Assert.Equal("abc", result.Name); + Assert.Equal(7, result.Count); + } + + [Fact] + public void Deserialize_NullJson_Throws() + { + var serializer = new JsonDataSerializer(); + var bytes = serializer.Serialize(null); + + Assert.Throws(() => serializer.Deserialize(bytes)); + } +} From a2bfb0de4e2f8fc22c611cfff59dfc1e2d834d3e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:45:19 +0200 Subject: [PATCH 77/98] refactor(messaging): use unified Core IDataSerializer/IDataDeserializer - register JsonDataSerializer for IDataSerializer/IDataDeserializer in RegisterCoreServices - MessageQueue depends on IDataSerializer + IDataDeserializer - remove IMessageSerializer and JsonMessageSerializer - in-memory and RabbitMq registrations register the default serializer if absent - update messaging tests (drop JsonMessageSerializerTests, covered by JsonDataSerializerTests) --- .../Interfaces/IMessageSerializer.cs | 13 --------- .../Services/JsonMessageSerializer.cs | 29 ------------------- .../Services/MessageQueue.cs | 11 ++++--- ...RabbitMqMessagingRegistrationExtensions.cs | 7 ++++- .../MessagingRegistrationExtensions.cs | 7 ++++- .../RegisterDefaultServicesExtensions.cs | 17 +++++++++++ .../Messaging/JsonMessageSerializerTests.cs | 26 ----------------- .../Messaging/MessageQueueTests.cs | 4 ++- .../MessagingRegistrationExtensionsTests.cs | 4 ++- 9 files changed, 42 insertions(+), 76 deletions(-) delete mode 100644 src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs delete mode 100644 src/SquidStd.Messaging.Abstractions/Services/JsonMessageSerializer.cs delete mode 100644 tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs deleted file mode 100644 index 7fec3dc9..00000000 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageSerializer.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SquidStd.Messaging.Abstractions.Interfaces; - -/// -/// Serializes and deserializes queue message payloads. -/// -public interface IMessageSerializer -{ - /// Serializes a message to bytes. - ReadOnlyMemory Serialize(TMessage message); - - /// Deserializes bytes to a message. - TMessage Deserialize(ReadOnlyMemory payload); -} diff --git a/src/SquidStd.Messaging.Abstractions/Services/JsonMessageSerializer.cs b/src/SquidStd.Messaging.Abstractions/Services/JsonMessageSerializer.cs deleted file mode 100644 index 4ef54aa6..00000000 --- a/src/SquidStd.Messaging.Abstractions/Services/JsonMessageSerializer.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using SquidStd.Messaging.Abstractions.Interfaces; - -namespace SquidStd.Messaging.Abstractions.Services; - -/// -/// Default JSON message serializer based on System.Text.Json. -/// -public sealed class JsonMessageSerializer : IMessageSerializer -{ - private readonly JsonSerializerOptions _options; - - public JsonMessageSerializer() - { - _options = new(JsonSerializerDefaults.Web); - } - - [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed."), - RequiresDynamicCode("JSON serialization may require runtime code generation.")] - public ReadOnlyMemory Serialize(TMessage message) - => JsonSerializer.SerializeToUtf8Bytes(message, _options); - - [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed."), - RequiresDynamicCode("JSON deserialization may require runtime code generation.")] - public TMessage Deserialize(ReadOnlyMemory payload) - => JsonSerializer.Deserialize(payload.Span, _options) ?? - throw new InvalidOperationException($"Deserialization returned null for type {typeof(TMessage).Name}."); -} diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs index 1f57faf1..7bcaa5bd 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs @@ -1,3 +1,4 @@ +using SquidStd.Core.Interfaces.Serialization; using SquidStd.Messaging.Abstractions.Interfaces; namespace SquidStd.Messaging.Abstractions.Services; @@ -9,12 +10,14 @@ namespace SquidStd.Messaging.Abstractions.Services; public sealed class MessageQueue : IMessageQueue { private readonly IQueueProvider _provider; - private readonly IMessageSerializer _serializer; + private readonly IDataSerializer _serializer; + private readonly IDataDeserializer _deserializer; - public MessageQueue(IQueueProvider provider, IMessageSerializer serializer) + public MessageQueue(IQueueProvider provider, IDataSerializer serializer, IDataDeserializer deserializer) { _provider = provider; _serializer = serializer; + _deserializer = deserializer; } /// @@ -30,7 +33,7 @@ public IDisposable Subscribe(string queueName, IQueueMessageListener { - listener.Handle(_serializer.Deserialize(payload)); + listener.Handle(_deserializer.Deserialize(payload)); return Task.CompletedTask; } @@ -44,7 +47,7 @@ public IDisposable Subscribe(string queueName, IQueueMessageListenerAs return _provider.Subscribe( queueName, - (payload, cancellationToken) => listener.HandleAsync(_serializer.Deserialize(payload), cancellationToken) + (payload, cancellationToken) => listener.HandleAsync(_deserializer.Deserialize(payload), cancellationToken) ); } } diff --git a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs index e6b16408..b56cd98c 100644 --- a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs @@ -1,5 +1,7 @@ using DryIoc; using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Abstractions.Services; @@ -28,7 +30,10 @@ public static IContainer AddRabbitMqMessaging( container.RegisterInstance(resolvedMessaging); container.RegisterInstance(options); - container.Register(Reuse.Singleton); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); var metrics = new MessagingMetricsProvider(); container.RegisterInstance(metrics); diff --git a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs index 5727be4b..92a3299a 100644 --- a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs @@ -1,5 +1,7 @@ using DryIoc; using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Abstractions.Services; @@ -23,7 +25,10 @@ public static IContainer AddInMemoryMessaging(this IContainer container, Messagi ArgumentNullException.ThrowIfNull(container); container.RegisterInstance(options ?? new MessagingOptions()); - container.Register(Reuse.Singleton); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); var metrics = new MessagingMetricsProvider(); container.RegisterInstance(metrics); diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 23884f9c..51d1d88b 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -13,9 +13,11 @@ using SquidStd.Core.Interfaces.Jobs; using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Core.Interfaces.Serialization; using SquidStd.Core.Interfaces.Storage; using SquidStd.Core.Interfaces.Threading; using SquidStd.Core.Interfaces.Timing; +using SquidStd.Core.Json; using SquidStd.Services.Core.Services; using SquidStd.Services.Core.Services.Storage; @@ -35,6 +37,20 @@ public static class RegisterDefaultServicesExtensions /// The logical config name or YAML file name. /// The directory where the config file is searched. /// The same container for chaining. + /// + /// Registers the default JSON data serializer for and + /// (same singleton instance). + /// + /// The same container for chaining. + public IContainer RegisterDataSerializer() + { + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + return container; + } + public IContainer RegisterConfigManagerService(string configName, string configDirectory) { var service = new ConfigManagerService(container, configName, configDirectory); @@ -62,6 +78,7 @@ public IContainer RegisterCoreServices() /// The same container for chaining. public IContainer RegisterCoreServices(string configName, string configDirectory) { + container.RegisterDataSerializer(); container.RegisterDefaultCoreConfigSections(); container.RegisterConfigManagerService(configName, configDirectory); container.RegisterEventBusService(); diff --git a/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs b/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs deleted file mode 100644 index 7c3f12b1..00000000 --- a/tests/SquidStd.Tests/Messaging/JsonMessageSerializerTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using SquidStd.Messaging.Abstractions.Interfaces; -using SquidStd.Messaging.Abstractions.Services; - -namespace SquidStd.Tests.Messaging; - -public class JsonMessageSerializerTests -{ - private sealed class Sample - { - public string Name { get; set; } = ""; - public int Count { get; set; } - } - - [Fact] - public void SerializeDeserialize_RoundTrips() - { - IMessageSerializer serializer = new JsonMessageSerializer(); - var original = new Sample { Name = "squid", Count = 7 }; - - var bytes = serializer.Serialize(original); - var restored = serializer.Deserialize(bytes); - - Assert.Equal("squid", restored.Name); - Assert.Equal(7, restored.Count); - } -} diff --git a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs index fb6d348c..f84be209 100644 --- a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs @@ -1,3 +1,4 @@ +using SquidStd.Core.Json; using SquidStd.Messaging.Extensions; using SquidStd.Messaging.Services; using SquidStd.Messaging.Abstractions.Data.Config; @@ -32,7 +33,8 @@ public Task HandleAsync(Order message, CancellationToken cancellationToken) public async Task PublishAsync_DeliversTypedMessageToListener() { await using var provider = new InMemoryQueueProvider(new MessagingOptions(), new MessagingMetricsProvider()); - IMessageQueue queue = new MessageQueue(provider, new JsonMessageSerializer()); + var serializer = new JsonDataSerializer(); + IMessageQueue queue = new MessageQueue(provider, serializer, serializer); var listener = new CapturingListener(); queue.Subscribe("orders", listener); diff --git a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs index 44592d4c..140608b0 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs @@ -1,5 +1,6 @@ using DryIoc; using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; using SquidStd.Messaging.Extensions; using SquidStd.Messaging.Services; using SquidStd.Messaging.Abstractions.Interfaces; @@ -17,7 +18,8 @@ public void AddInMemoryMessaging_RegistersResolvableServices() Assert.NotNull(container.Resolve()); Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); Assert.NotNull(container.Resolve()); Assert.Contains(container.Resolve>(), p => p.ProviderName == "messaging"); From 8e25a935ad6e2b2aebad6a5d0cc4ffb7a1b221ab Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:47:14 +0200 Subject: [PATCH 78/98] feat(caching): add Caching.Abstractions interfaces, options and connection string --- SquidStd.slnx | 1 + .../Data/Config/CacheConnectionString.cs | 87 +++++++++++++++++++ .../Data/Config/CacheOptions.cs | 13 +++ .../Interfaces/ICacheMetrics.cs | 19 ++++ .../Interfaces/ICacheProvider.cs | 21 +++++ .../Interfaces/ICacheService.cs | 27 ++++++ .../SquidStd.Caching.Abstractions.csproj | 15 ++++ .../Caching/CacheConnectionStringTests.cs | 47 ++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 9 files changed, 231 insertions(+) create mode 100644 src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs create mode 100644 src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs create mode 100644 src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs create mode 100644 src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs create mode 100644 src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs create mode 100644 src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj create mode 100644 tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index 39c21dd0..ffa73ad3 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -2,6 +2,7 @@ + diff --git a/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs new file mode 100644 index 00000000..33ab8f4f --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs @@ -0,0 +1,87 @@ +using System.Collections.Frozen; +using System.Web; + +namespace SquidStd.Caching.Abstractions.Data.Config; + +/// +/// Parsed cache connection string of the form scheme://[user:pass@]host[:port][?params]. +/// +public sealed class CacheConnectionString +{ + private CacheConnectionString( + string scheme, + string host, + int? port, + string? userName, + string? password, + IReadOnlyDictionary parameters + ) + { + Scheme = scheme; + Host = host; + Port = port; + UserName = userName; + Password = password; + Parameters = parameters; + } + + /// URI scheme, e.g. "memory" or "redis". + public string Scheme { get; } + + /// Host component. + public string Host { get; } + + /// Port, when specified. + public int? Port { get; } + + /// User name from the user-info component, when present. + public string? UserName { get; } + + /// Password from the user-info component, when present. + public string? Password { get; } + + /// Query-string parameters. + public IReadOnlyDictionary Parameters { get; } + + /// Parses a cache connection string. + public static CacheConnectionString Parse(string connectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var uri = new Uri(connectionString, UriKind.Absolute); + + string? userName = null; + string? password = null; + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var parts = uri.UserInfo.Split(':', 2); + userName = Uri.UnescapeDataString(parts[0]); + password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : null; + } + + var query = HttpUtility.ParseQueryString(uri.Query); + var parameters = query.AllKeys + .Where(static key => key is not null) + .ToFrozenDictionary(key => key!, key => query[key] ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + return new( + uri.Scheme, + uri.Host, + uri.Port > 0 ? uri.Port : null, + userName, + password, + parameters + ); + } + + /// Builds from the query parameters. + public CacheOptions ToCacheOptions() + => new() + { + DefaultTtl = Parameters.TryGetValue("defaultTtlSeconds", out var ttl) && int.TryParse(ttl, out var seconds) + ? TimeSpan.FromSeconds(seconds) + : null, + KeyPrefix = Parameters.TryGetValue("keyPrefix", out var prefix) ? prefix : string.Empty + }; +} diff --git a/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs b/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs new file mode 100644 index 00000000..68fda982 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Caching.Abstractions.Data.Config; + +/// +/// Options shared by all cache providers. +/// +public sealed class CacheOptions +{ + /// Default time-to-live applied when a set call passes no TTL. null means no expiry. + public TimeSpan? DefaultTtl { get; set; } + + /// Prefix prepended to every key. Default empty. + public string KeyPrefix { get; set; } = string.Empty; +} diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs new file mode 100644 index 00000000..cf7be0af --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs @@ -0,0 +1,19 @@ +namespace SquidStd.Caching.Abstractions.Interfaces; + +/// +/// Sink for cache instrumentation events. +/// +public interface ICacheMetrics +{ + /// Records a cache hit. + void OnHit(string key); + + /// Records a cache miss. + void OnMiss(string key); + + /// Records a value being stored. + void OnSet(string key); + + /// Records a key being removed. + void OnRemove(string key); +} diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs new file mode 100644 index 00000000..7b27ddab --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs @@ -0,0 +1,21 @@ +using SquidStd.Abstractions.Interfaces.Services; + +namespace SquidStd.Caching.Abstractions.Interfaces; + +/// +/// Byte-level cache backend. Implemented by each provider (in-memory, Redis, ...). +/// +public interface ICacheProvider : ISquidStdService +{ + /// Gets the raw value for a key, or null when absent. + Task?> GetAsync(string key, CancellationToken cancellationToken = default); + + /// Stores a raw value with an optional time-to-live. + Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default); + + /// Removes a key; returns whether it existed. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// Returns whether a key exists. + Task ExistsAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs new file mode 100644 index 00000000..8d8dec96 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs @@ -0,0 +1,27 @@ +namespace SquidStd.Caching.Abstractions.Interfaces; + +/// +/// Typed cache-aside facade over an . +/// +public interface ICacheService +{ + /// Gets a typed value, or default when absent. + Task GetAsync(string key, CancellationToken cancellationToken = default); + + /// Stores a typed value with an optional time-to-live (falls back to the default TTL). + Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default); + + /// Removes a key; returns whether it existed. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// Returns whether a key exists. + Task ExistsAsync(string key, CancellationToken cancellationToken = default); + + /// Returns the cached value, or computes, stores and returns it on a miss. + Task GetOrSetAsync( + string key, + Func> factory, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ); +} diff --git a/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj b/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj new file mode 100644 index 00000000..f73ae1d5 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs b/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs new file mode 100644 index 00000000..f588e5f9 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs @@ -0,0 +1,47 @@ +using SquidStd.Caching.Abstractions.Data.Config; + +namespace SquidStd.Tests.Caching; + +public class CacheConnectionStringTests +{ + [Fact] + public void Parse_Memory_ReadsScheme() + { + var cs = CacheConnectionString.Parse("memory://localhost"); + + Assert.Equal("memory", cs.Scheme); + Assert.Equal("localhost", cs.Host); + } + + [Fact] + public void Parse_Redis_ReadsHostPortAndUserInfo() + { + var cs = CacheConnectionString.Parse("redis://user:pass@cache-host:6380"); + + Assert.Equal("redis", cs.Scheme); + Assert.Equal("cache-host", cs.Host); + Assert.Equal(6380, cs.Port); + Assert.Equal("user", cs.UserName); + Assert.Equal("pass", cs.Password); + } + + [Fact] + public void ToCacheOptions_ReadsTtlAndPrefix() + { + var cs = CacheConnectionString.Parse("memory://localhost?defaultTtlSeconds=30&keyPrefix=app:"); + + var options = cs.ToCacheOptions(); + + Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultTtl); + Assert.Equal("app:", options.KeyPrefix); + } + + [Fact] + public void ToCacheOptions_Defaults_WhenNoParams() + { + var options = CacheConnectionString.Parse("memory://localhost").ToCacheOptions(); + + Assert.Null(options.DefaultTtl); + Assert.Equal(string.Empty, options.KeyPrefix); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index b9b49e9d..c5b29ce7 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -41,6 +41,7 @@ + From 3cf20ac082514bdb807ee106e226a37f4d53f53d Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:48:08 +0200 Subject: [PATCH 79/98] docs(messaging): fix README inaccuracies - RabbitMq: connection string uses rabbitmq:// scheme (not amqp://) - Messaging.Abstractions: MessagingOptions has no prefetch (that is RabbitMqOptions) --- src/SquidStd.Messaging.Abstractions/README.md | 2 +- src/SquidStd.Messaging.RabbitMq/README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SquidStd.Messaging.Abstractions/README.md b/src/SquidStd.Messaging.Abstractions/README.md index fa11943d..c9046c3c 100644 --- a/src/SquidStd.Messaging.Abstractions/README.md +++ b/src/SquidStd.Messaging.Abstractions/README.md @@ -56,7 +56,7 @@ public async Task PublishAsync(IMessageQueue queue) | `IMessageSerializer` | Payload (de)serialization. | | `IQueueMessageListener` | Subscriber callbacks. | | `IMessagingMetrics` | Delivery metrics sink. | -| `MessagingOptions` | DLQ/retry/prefetch configuration. | +| `MessagingOptions` | Delivery attempts, retry delay, and dead-letter-queue suffix. | ## License diff --git a/src/SquidStd.Messaging.RabbitMq/README.md b/src/SquidStd.Messaging.RabbitMq/README.md index 27ee2b30..69578ac6 100644 --- a/src/SquidStd.Messaging.RabbitMq/README.md +++ b/src/SquidStd.Messaging.RabbitMq/README.md @@ -24,8 +24,8 @@ dotnet add package SquidStd.Messaging.RabbitMq - One-line registration: `container.AddRabbitMqMessaging(connectionString)` or with `RabbitMqOptions`. - Broker-backed `IQueueProvider` reusing the shared `IMessageQueue` facade and serializer. -- Connection via AMQP URI or explicit host/port/vhost/credentials (`RabbitMqOptions`). -- Configurable consumer prefetch count. +- Connection via a `rabbitmq://` connection string or explicit `RabbitMqOptions` (host/port/vhost/credentials). +- Configurable consumer prefetch count (`?prefetch=` on the connection string, or `RabbitMqOptions.PrefetchCount`). ## Usage @@ -35,7 +35,7 @@ using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.RabbitMq.Extensions; var container = new Container(); -container.AddRabbitMqMessaging("amqp://guest:guest@localhost:5672/"); +container.AddRabbitMqMessaging("rabbitmq://guest:guest@localhost:5672/"); var queue = container.Resolve(); await queue.PublishAsync("orders", new { Id = 1 }); From 81f6276897a6acc293ac81ef382de1cc01313985 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:48:20 +0200 Subject: [PATCH 80/98] feat(caching): add CacheMetricsProvider and NoOpCacheMetrics --- .../Services/CacheMetricsProvider.cs | 56 +++++++++++++++++++ .../Services/NoOpCacheMetrics.cs | 28 ++++++++++ .../Caching/CacheMetricsProviderTests.cs | 31 ++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs create mode 100644 src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs create mode 100644 tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs diff --git a/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs new file mode 100644 index 00000000..36722cb3 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs @@ -0,0 +1,56 @@ +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Types.Metrics; + +namespace SquidStd.Caching.Abstractions.Services; + +/// +/// Accumulates aggregate cache metrics and exposes them to the metrics collection system. +/// +public sealed class CacheMetricsProvider : ICacheMetrics, IMetricProvider +{ + private long _hits; + private long _misses; + private long _sets; + private long _removes; + + /// + public string ProviderName => "cache"; + + /// + public void OnHit(string key) + => Interlocked.Increment(ref _hits); + + /// + public void OnMiss(string key) + => Interlocked.Increment(ref _misses); + + /// + public void OnSet(string key) + => Interlocked.Increment(ref _sets); + + /// + public void OnRemove(string key) + => Interlocked.Increment(ref _removes); + + /// + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + var hits = Interlocked.Read(ref _hits); + var misses = Interlocked.Read(ref _misses); + var total = hits + misses; + var hitRatio = total == 0 ? 0d : (double)hits / total; + + var samples = new List + { + new("hits", hits, Type: MetricType.Counter), + new("misses", misses, Type: MetricType.Counter), + new("sets", Interlocked.Read(ref _sets), Type: MetricType.Counter), + new("removes", Interlocked.Read(ref _removes), Type: MetricType.Counter), + new("hit_ratio", hitRatio) + }; + + return ValueTask.FromResult>(samples); + } +} diff --git a/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs b/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs new file mode 100644 index 00000000..c52877a1 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs @@ -0,0 +1,28 @@ +using SquidStd.Caching.Abstractions.Interfaces; + +namespace SquidStd.Caching.Abstractions.Services; + +/// +/// Metrics sink that ignores all events. Used when no metrics are configured. +/// +public sealed class NoOpCacheMetrics : ICacheMetrics +{ + /// Shared instance. + public static NoOpCacheMetrics Instance { get; } = new(); + + public void OnHit(string key) + { + } + + public void OnMiss(string key) + { + } + + public void OnSet(string key) + { + } + + public void OnRemove(string key) + { + } +} diff --git a/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs new file mode 100644 index 00000000..c64f83c6 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs @@ -0,0 +1,31 @@ +using SquidStd.Caching.Abstractions.Services; + +namespace SquidStd.Tests.Caching; + +public class CacheMetricsProviderTests +{ + [Fact] + public void ProviderName_IsCache() + => Assert.Equal("cache", new CacheMetricsProvider().ProviderName); + + [Fact] + public async Task CollectAsync_ReportsCountersAndHitRatio() + { + var metrics = new CacheMetricsProvider(); + metrics.OnHit("a"); + metrics.OnHit("b"); + metrics.OnMiss("c"); + metrics.OnSet("c"); + metrics.OnRemove("a"); + + var samples = await metrics.CollectAsync(); + + double Value(string name) => samples.Single(s => s.Name == name).Value; + + Assert.Equal(2, Value("hits")); + Assert.Equal(1, Value("misses")); + Assert.Equal(1, Value("sets")); + Assert.Equal(1, Value("removes")); + Assert.Equal(2d / 3d, Value("hit_ratio"), 3); + } +} From 2b3d0bf48914ffc8d55d4089fd63c0d123d08555 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:52:43 +0200 Subject: [PATCH 81/98] feat(caching): add typed CacheService facade with cache-aside --- .../Services/CacheService.cs | 108 +++++++++++++++++ .../Caching/CacheServiceTests.cs | 110 ++++++++++++++++++ .../Support/FakeCacheProvider.cs | 44 +++++++ 3 files changed, 262 insertions(+) create mode 100644 src/SquidStd.Caching.Abstractions/Services/CacheService.cs create mode 100644 tests/SquidStd.Tests/Caching/CacheServiceTests.cs create mode 100644 tests/SquidStd.Tests/Support/FakeCacheProvider.cs diff --git a/src/SquidStd.Caching.Abstractions/Services/CacheService.cs b/src/SquidStd.Caching.Abstractions/Services/CacheService.cs new file mode 100644 index 00000000..be189fa6 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Services/CacheService.cs @@ -0,0 +1,108 @@ +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Core.Interfaces.Serialization; + +namespace SquidStd.Caching.Abstractions.Services; + +/// +/// Typed cache-aside facade: serializes values, applies the key prefix and default TTL, and +/// implements once over any . +/// +public sealed class CacheService : ICacheService +{ + private readonly ICacheProvider _provider; + private readonly IDataSerializer _serializer; + private readonly IDataDeserializer _deserializer; + private readonly ICacheMetrics _metrics; + private readonly TimeSpan? _defaultTtl; + private readonly string _keyPrefix; + + public CacheService( + ICacheProvider provider, + IDataSerializer serializer, + IDataDeserializer deserializer, + CacheOptions options, + ICacheMetrics? metrics = null + ) + { + ArgumentNullException.ThrowIfNull(options); + + _provider = provider; + _serializer = serializer; + _deserializer = deserializer; + _metrics = metrics ?? NoOpCacheMetrics.Instance; + _defaultTtl = options.DefaultTtl; + _keyPrefix = options.KeyPrefix; + } + + /// + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var bytes = await _provider.GetAsync(Prefixed(key), cancellationToken); + + if (!bytes.HasValue) + { + _metrics.OnMiss(key); + + return default; + } + + _metrics.OnHit(key); + + return _deserializer.Deserialize(bytes.Value); + } + + /// + public async Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default) + { + var bytes = _serializer.Serialize(value); + await _provider.SetAsync(Prefixed(key), bytes, ttl ?? _defaultTtl, cancellationToken); + _metrics.OnSet(key); + } + + /// + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var removed = await _provider.RemoveAsync(Prefixed(key), cancellationToken); + + if (removed) + { + _metrics.OnRemove(key); + } + + return removed; + } + + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => _provider.ExistsAsync(Prefixed(key), cancellationToken); + + /// + public async Task GetOrSetAsync( + string key, + Func> factory, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(factory); + + var bytes = await _provider.GetAsync(Prefixed(key), cancellationToken); + + if (bytes.HasValue) + { + _metrics.OnHit(key); + + return _deserializer.Deserialize(bytes.Value); + } + + _metrics.OnMiss(key); + var value = await factory(cancellationToken); + await SetAsync(key, value, ttl, cancellationToken); + + return value; + } + + private string Prefixed(string key) + => _keyPrefix.Length == 0 ? key : _keyPrefix + key; +} diff --git a/tests/SquidStd.Tests/Caching/CacheServiceTests.cs b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs new file mode 100644 index 00000000..c458ada4 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs @@ -0,0 +1,110 @@ +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Services; +using SquidStd.Core.Json; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Caching; + +public class CacheServiceTests +{ + private static CacheService NewService( + FakeCacheProvider provider, + CacheOptions? options = null, + CacheMetricsProvider? metrics = null + ) + { + var serializer = new JsonDataSerializer(); + + return new CacheService(provider, serializer, serializer, options ?? new CacheOptions(), metrics); + } + + [Fact] + public async Task SetThenGet_RoundTrips() + { + var service = NewService(new FakeCacheProvider()); + + await service.SetAsync("k", 42); + + Assert.Equal(42, await service.GetAsync("k")); + } + + [Fact] + public async Task Get_Missing_ReturnsDefault() + => Assert.Null(await NewService(new FakeCacheProvider()).GetAsync("absent")); + + [Fact] + public async Task Set_UsesDefaultTtl_WhenNoneGiven() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); + + await service.SetAsync("k", "v"); + + Assert.Equal(TimeSpan.FromSeconds(30), provider.LastTtl); + } + + [Fact] + public async Task Set_PerEntryTtl_OverridesDefault() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); + + await service.SetAsync("k", "v", TimeSpan.FromSeconds(5)); + + Assert.Equal(TimeSpan.FromSeconds(5), provider.LastTtl); + } + + [Fact] + public async Task KeyPrefix_IsAppliedToProvider() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider, new CacheOptions { KeyPrefix = "app:" }); + + await service.SetAsync("k", "v"); + + Assert.True(await provider.ExistsAsync("app:k")); + Assert.False(await provider.ExistsAsync("k")); + } + + [Fact] + public async Task GetOrSet_Miss_InvokesFactoryAndStores() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider); + var calls = 0; + + var value = await service.GetOrSetAsync("k", _ => { calls++; return Task.FromResult(99); }); + + Assert.Equal(99, value); + Assert.Equal(1, calls); + Assert.Equal(99, await service.GetAsync("k")); + } + + [Fact] + public async Task GetOrSet_Hit_DoesNotInvokeFactory() + { + var service = NewService(new FakeCacheProvider()); + await service.SetAsync("k", 7); + var calls = 0; + + var value = await service.GetOrSetAsync("k", _ => { calls++; return Task.FromResult(0); }); + + Assert.Equal(7, value); + Assert.Equal(0, calls); + } + + [Fact] + public async Task Metrics_RecordHitAndMiss() + { + var metrics = new CacheMetricsProvider(); + var service = NewService(new FakeCacheProvider(), metrics: metrics); + + await service.GetAsync("absent"); // miss + await service.SetAsync("k", 1); + await service.GetAsync("k"); // hit + + var samples = await metrics.CollectAsync(); + Assert.Equal(1, samples.Single(s => s.Name == "hits").Value); + Assert.Equal(1, samples.Single(s => s.Name == "misses").Value); + } +} diff --git a/tests/SquidStd.Tests/Support/FakeCacheProvider.cs b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs new file mode 100644 index 00000000..8dd5120a --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; +using SquidStd.Caching.Abstractions.Interfaces; + +namespace SquidStd.Tests.Support; + +/// +/// In-memory for tests. Ignores TTL expiry (records the last TTL seen). +/// +public sealed class FakeCacheProvider : ICacheProvider +{ + private readonly ConcurrentDictionary _store = new(StringComparer.Ordinal); + + public TimeSpan? LastTtl { get; private set; } + + public Task?> GetAsync(string key, CancellationToken cancellationToken = default) + { + if (_store.TryGetValue(key, out var value)) + { + return Task.FromResult?>(new ReadOnlyMemory(value)); + } + + return Task.FromResult?>(null); + } + + public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + { + LastTtl = ttl; + _store[key] = value.ToArray(); + + return Task.CompletedTask; + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_store.TryRemove(key, out _)); + + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_store.ContainsKey(key)); + + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; +} From cde6ea6fa572b1f1f1f2d18441d61616dee215b7 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:55:25 +0200 Subject: [PATCH 82/98] feat(caching): add in-memory cache provider and registration --- SquidStd.slnx | 1 + .../Extensions/CacheRegistrationExtensions.cs | 58 +++++++++++++++++ .../Services/InMemoryCacheProvider.cs | 64 +++++++++++++++++++ src/SquidStd.Caching/SquidStd.Caching.csproj | 19 ++++++ .../Caching/CacheRegistrationTests.cs | 39 +++++++++++ .../Caching/InMemoryCacheProviderTests.cs | 51 +++++++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 7 files changed, 233 insertions(+) create mode 100644 src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs create mode 100644 src/SquidStd.Caching/Services/InMemoryCacheProvider.cs create mode 100644 src/SquidStd.Caching/SquidStd.Caching.csproj create mode 100644 tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs create mode 100644 tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index ffa73ad3..f276964a 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -3,6 +3,7 @@ + diff --git a/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs b/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs new file mode 100644 index 00000000..06875df5 --- /dev/null +++ b/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs @@ -0,0 +1,58 @@ +using DryIoc; +using Microsoft.Extensions.Caching.Memory; +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Abstractions.Services; +using SquidStd.Caching.Services; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; + +namespace SquidStd.Caching.Extensions; + +/// +/// DryIoc registration helpers for the in-memory cache. +/// +public static class CacheRegistrationExtensions +{ + /// Registers the in-memory cache (provider, facade, metrics, serializer). + public static IContainer AddInMemoryCache(this IContainer container, CacheOptions? options = null) + { + ArgumentNullException.ThrowIfNull(container); + + container.RegisterInstance(options ?? new CacheOptions()); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + container.RegisterInstance(new MemoryCache(new MemoryCacheOptions()), IfAlreadyRegistered.Keep); + + var metrics = new CacheMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + + /// Registers the in-memory cache from a connection string (scheme must be "memory"). + public static IContainer AddInMemoryCache(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = CacheConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "memory", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'memory://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + return container.AddInMemoryCache(cs.ToCacheOptions()); + } +} diff --git a/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs new file mode 100644 index 00000000..1d8934e8 --- /dev/null +++ b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Caching.Memory; +using SquidStd.Caching.Abstractions.Interfaces; + +namespace SquidStd.Caching.Services; + +/// +/// In-memory backed by . +/// +public sealed class InMemoryCacheProvider : ICacheProvider +{ + private readonly IMemoryCache _cache; + + public InMemoryCacheProvider(IMemoryCache cache) + { + _cache = cache; + } + + /// + public Task?> GetAsync(string key, CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(key, out byte[]? value) && value is not null) + { + return Task.FromResult?>(new ReadOnlyMemory(value)); + } + + return Task.FromResult?>(null); + } + + /// + public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + { + var options = new MemoryCacheEntryOptions(); + + if (ttl is not null) + { + options.AbsoluteExpirationRelativeToNow = ttl; + } + + _cache.Set(key, value.ToArray(), options); + + return Task.CompletedTask; + } + + /// + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var existed = _cache.TryGetValue(key, out _); + _cache.Remove(key); + + return Task.FromResult(existed); + } + + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_cache.TryGetValue(key, out _)); + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; +} diff --git a/src/SquidStd.Caching/SquidStd.Caching.csproj b/src/SquidStd.Caching/SquidStd.Caching.csproj new file mode 100644 index 00000000..2e317d0f --- /dev/null +++ b/src/SquidStd.Caching/SquidStd.Caching.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs b/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs new file mode 100644 index 00000000..693f8c90 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs @@ -0,0 +1,39 @@ +using DryIoc; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Extensions; + +namespace SquidStd.Tests.Caching; + +public class CacheRegistrationTests +{ + [Fact] + public void AddInMemoryCache_ResolvesService() + { + var container = new Container(); + + container.AddInMemoryCache(); + + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + } + + [Fact] + public void AddInMemoryCache_FromUrl_WrongScheme_Throws() + { + var container = new Container(); + + Assert.Throws(() => container.AddInMemoryCache("redis://localhost")); + } + + [Fact] + public async Task AddInMemoryCache_FromUrl_ResolvesAndWorks() + { + var container = new Container(); + + container.AddInMemoryCache("memory://localhost?keyPrefix=app:"); + var cache = container.Resolve(); + + await cache.SetAsync("k", 5); + Assert.Equal(5, await cache.GetAsync("k")); + } +} diff --git a/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs new file mode 100644 index 00000000..810c8818 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Caching.Memory; +using SquidStd.Caching.Services; + +namespace SquidStd.Tests.Caching; + +public class InMemoryCacheProviderTests +{ + private static InMemoryCacheProvider NewProvider() + => new(new MemoryCache(new MemoryCacheOptions())); + + private static ReadOnlyMemory Bytes(string s) => System.Text.Encoding.UTF8.GetBytes(s); + + [Fact] + public async Task SetThenGet_ReturnsValue() + { + var provider = NewProvider(); + await provider.SetAsync("k", Bytes("v"), null); + + var value = await provider.GetAsync("k"); + + Assert.NotNull(value); + Assert.Equal("v", System.Text.Encoding.UTF8.GetString(value!.Value.Span)); + } + + [Fact] + public async Task Get_Missing_ReturnsNull() + => Assert.Null(await NewProvider().GetAsync("absent")); + + [Fact] + public async Task Exists_And_Remove() + { + var provider = NewProvider(); + await provider.SetAsync("k", Bytes("v"), null); + + Assert.True(await provider.ExistsAsync("k")); + Assert.True(await provider.RemoveAsync("k")); + Assert.False(await provider.ExistsAsync("k")); + Assert.False(await provider.RemoveAsync("k")); + } + + [Fact] + public async Task Ttl_Expires() + { + var provider = NewProvider(); + await provider.SetAsync("k", Bytes("v"), TimeSpan.FromMilliseconds(50)); + + await Task.Delay(120); + + Assert.Null(await provider.GetAsync("k")); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index c5b29ce7..175c9640 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -42,6 +42,7 @@ + From 4fdc8684f260c001c91cfb61027ef155fabf1afc Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:57:53 +0200 Subject: [PATCH 83/98] feat(caching): add Redis cache provider and registration --- SquidStd.slnx | 1 + .../Data/Config/RedisCacheOptions.cs | 10 +++ .../RedisCacheRegistrationExtensions.cs | 66 ++++++++++++++++ .../Services/RedisCacheProvider.cs | 76 +++++++++++++++++++ .../SquidStd.Caching.Redis.csproj | 19 +++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 6 files changed, 173 insertions(+) create mode 100644 src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs create mode 100644 src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs create mode 100644 src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs create mode 100644 src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj diff --git a/SquidStd.slnx b/SquidStd.slnx index f276964a..12acb065 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -3,6 +3,7 @@ + diff --git a/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs b/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs new file mode 100644 index 00000000..30dd72d4 --- /dev/null +++ b/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Caching.Redis.Data.Config; + +/// +/// Connection options for the Redis cache provider. +/// +public sealed class RedisCacheOptions +{ + /// StackExchange.Redis configuration string. Default "localhost:6379". + public string Configuration { get; init; } = "localhost:6379"; +} diff --git a/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs b/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs new file mode 100644 index 00000000..24eda31a --- /dev/null +++ b/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs @@ -0,0 +1,66 @@ +using DryIoc; +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Abstractions.Services; +using SquidStd.Caching.Redis.Data.Config; +using SquidStd.Caching.Redis.Services; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; + +namespace SquidStd.Caching.Redis.Extensions; + +/// +/// DryIoc registration helpers for the Redis cache provider. +/// +public static class RedisCacheRegistrationExtensions +{ + /// Registers the Redis cache from explicit options. + public static IContainer AddRedisCache( + this IContainer container, + RedisCacheOptions options, + CacheOptions? cacheOptions = null + ) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + + container.RegisterInstance(cacheOptions ?? new CacheOptions()); + container.RegisterInstance(options); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + var metrics = new CacheMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + + /// Registers the Redis cache from a connection string (scheme must be "redis"). + public static IContainer AddRedisCache(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = CacheConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "redis", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'redis://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + var host = string.IsNullOrEmpty(cs.Host) ? "localhost" : cs.Host; + var port = cs.Port ?? 6379; + var options = new RedisCacheOptions { Configuration = $"{host}:{port}" }; + + return container.AddRedisCache(options, cs.ToCacheOptions()); + } +} diff --git a/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs new file mode 100644 index 00000000..2da7d8a9 --- /dev/null +++ b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs @@ -0,0 +1,76 @@ +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Redis.Data.Config; +using StackExchange.Redis; + +namespace SquidStd.Caching.Redis.Services; + +/// +/// Redis backed by a StackExchange.Redis connection multiplexer. +/// +public sealed class RedisCacheProvider : ICacheProvider, IAsyncDisposable +{ + private readonly RedisCacheOptions _options; + private IConnectionMultiplexer? _connection; + private int _disposed; + + public RedisCacheProvider(RedisCacheOptions options) + { + _options = options; + } + + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + _connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration); + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + + /// + public async Task?> GetAsync(string key, CancellationToken cancellationToken = default) + { + var value = await Database.StringGetAsync(key); + + if (value.IsNull) + { + return null; + } + + return new ReadOnlyMemory((byte[])value!); + } + + /// + public async Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + { + var expiry = ttl is null ? Expiration.Default : new Expiration(ttl.Value); + await Database.StringSetAsync(key, value.ToArray(), expiry); + } + + /// + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + => Database.KeyDeleteAsync(key); + + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Database.KeyExistsAsync(key); + + private IDatabase Database + => (_connection ?? throw new InvalidOperationException("Provider not started.")).GetDatabase(); + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_connection is not null) + { + await _connection.CloseAsync(); + _connection.Dispose(); + } + } +} diff --git a/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj b/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj new file mode 100644 index 00000000..4ea81181 --- /dev/null +++ b/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 175c9640..a3f6700c 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -43,6 +43,7 @@ + From d56818d245976c8756991c6c047140aac0c3680f Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 16:59:13 +0200 Subject: [PATCH 84/98] test(caching): add Redis integration tests with Testcontainers --- .../Caching/Redis/RedisCacheProviderTests.cs | 65 +++++++++++++++++++ .../Caching/Redis/RedisContainerFixture.cs | 25 +++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 3 files changed, 91 insertions(+) create mode 100644 tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs create mode 100644 tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs new file mode 100644 index 00000000..27918634 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs @@ -0,0 +1,65 @@ +using System.Text; +using SquidStd.Caching.Redis.Data.Config; +using SquidStd.Caching.Redis.Services; + +namespace SquidStd.Tests.Caching.Redis; + +[Collection(RedisCollection.Name)] +public class RedisCacheProviderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private readonly RedisContainerFixture _fixture; + + public RedisCacheProviderTests(RedisContainerFixture fixture) + { + _fixture = fixture; + } + + private RedisCacheProvider NewProvider() + => new(new RedisCacheOptions { Configuration = _fixture.ConnectionString }); + + private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); + private static string Text(ReadOnlyMemory b) => Encoding.UTF8.GetString(b.Span); + private static string Key() => "k-" + Guid.NewGuid().ToString("N"); + + [Fact] + public async Task SetThenGet_RoundTrips() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var key = Key(); + + await provider.SetAsync(key, Bytes("hello"), null); + var value = await provider.GetAsync(key); + + Assert.NotNull(value); + Assert.Equal("hello", Text(value!.Value)); + } + + [Fact] + public async Task Exists_And_Remove() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var key = Key(); + await provider.SetAsync(key, Bytes("v"), null); + + Assert.True(await provider.ExistsAsync(key)); + Assert.True(await provider.RemoveAsync(key)); + Assert.False(await provider.ExistsAsync(key)); + } + + [Fact] + public async Task Ttl_Expires() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var key = Key(); + + await provider.SetAsync(key, Bytes("v"), TimeSpan.FromMilliseconds(200)); + await Task.Delay(500); + + Assert.Null(await provider.GetAsync(key).WaitAsync(Timeout)); + } +} diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs new file mode 100644 index 00000000..e6ee7358 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs @@ -0,0 +1,25 @@ +using Testcontainers.Redis; + +namespace SquidStd.Tests.Caching.Redis; + +/// +/// Starts a Redis container once for the whole collection and exposes its connection string. +/// +public sealed class RedisContainerFixture : IAsyncLifetime +{ + private readonly RedisContainer _container = new RedisBuilder().Build(); + + public string ConnectionString => _container.GetConnectionString(); + + public Task InitializeAsync() + => _container.StartAsync(); + + public Task DisposeAsync() + => _container.DisposeAsync().AsTask(); +} + +[CollectionDefinition(Name)] +public sealed class RedisCollection : ICollectionFixture +{ + public const string Name = "Redis"; +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index a3f6700c..6cf2d3ec 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -12,6 +12,7 @@ + From ea6d00c3b64e877a0a33e502f55b12b36996f3b2 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:02:49 +0200 Subject: [PATCH 85/98] docs(site): scaffold DocFX project (config, images, gitignore) --- docs/.gitignore | 4 ++++ docs/docfx.json | 34 ++++++++++++++++++++++++++++++++++ docs/images/favicon.png | Bin 0 -> 2658 bytes docs/images/logo.png | Bin 0 -> 14418 bytes 4 files changed, 38 insertions(+) create mode 100644 docs/.gitignore create mode 100644 docs/docfx.json create mode 100644 docs/images/favicon.png create mode 100644 docs/images/logo.png diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..b7772619 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,4 @@ +_site/ +api/*.yml +api/.manifest +obj/ diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 00000000..399777c9 --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,34 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "../", + "files": [ "src/**/*.csproj" ], + "exclude": [ "**/bin/**", "**/obj/**" ] + } + ], + "dest": "api", + "properties": { "TargetFramework": "net10.0" } + } + ], + "build": { + "content": [ + { "files": [ "api/**.{yml,md}" ] }, + { "files": [ "articles/**.{md,yml}", "*.md", "toc.yml" ] } + ], + "resource": [ + { "files": [ "images/**" ] } + ], + "output": "_site", + "template": [ "default", "modern", "templates/squidstd" ], + "globalMetadata": { + "_appName": "SquidStd", + "_appTitle": "SquidStd", + "_appLogoPath": "images/logo.png", + "_appFaviconPath": "images/favicon.png", + "_enableSearch": true, + "_gitContribute": { "repo": "https://github.com/tgiachi/SquidStd", "branch": "main" } + } + } +} diff --git a/docs/images/favicon.png b/docs/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..948c92e75f15a32ea9289abd186324e7c53ed442 GIT binary patch literal 2658 zcma);cTm&W8is!m2noHp2nb|Fr3ey=AVH84gn)!1B@|-<1PO>?6N*b0DT%H~k);R- z2!f#~T|f*)1z{CrDN2_vh#*Ud(sIMz8Ry<{@64Su=bZPv-}lUS%0Gu@hqe&nm*EEh zKnP`tbl}Xe{SEYV$)N8Vo6PfFlQ}T}mszYU~qke7963i_kVl8fJOI@1@^SIKmzRjmfup6#&P(9P&Vd#OW>bG zxHQ}Ir9W`2qe14FAa8sS0pUd^aHhYDsKa&DwX_^HPa@!&2u;o7a5w@EuM>Y<`(Ho+ z$(!i&>;DFx+d?25K>2%xAYuT45`+&3{E^Yo|96o^C*lBy`FWoqTqXJ-keod^a=3pw zyf3Rln3JsfLo$+hi5Nr-BY6K4H9d8Fe_JX)P_|?ab_IX=pP=n<^DYi_;5%f4_b2@4 zgYoC_pWs|D3xzas#B|KLxCc57O7HE}X)NUAxC_5RaSJsX!+xfjAmi=DitFvw6bX+< zKAK%ZNf~s>^ePw$eWFy zT87=C70}7{)Yz4~{+k`yhW@*3Ry4bTQK!2`R0w)2>pW?f4F{E9y68Ye^B8UPo4?T3 z2r8($;-wNA01p=u2A#l3BIB3Cnk*Ny?9W8%gb z5zYDp13QSR^*45(E(r46B%HL-fgU`TEvV)R_pB-Rej6ILPX=QkY*2?pwLY$sx$mb*k%c#hf>pJI7RP&|$bjd{ zYg}<_T3Xvu-SC8TKtM}RrRA}bYF^3p6B70h*;D;sbrjn6mXs}PuiGGGRz%WT5(Qa| zjK14kM>%$}L98jcbNtb#Hk#h;sN$vdo>gY-hd4YYr&p@r2MGx;}jnUO5hkserckQnURlqmt$|E^9OyS?iZY9P=@fjc&Q}v4Jzdk8zIF3T$*)co=r@GP;Y)szXINQn zMW9B=vC0Z-q!aA zPP7Jm9tKlB*i>FJLx&5@Omii}FiZh=?pJiDJXPKX%Aci7GS$SCLKA%8uukqTWm4cZ z=k46M+Hw3~XPP(pqlHQ&TG7)-6sKixd|f}M*OMY$m5ExmydbcWsYZ>m1@Y+op7j)u zrPg#NVk~BAUuQiqEzf~KV!G5KRis|Eme)dYsc!d=dLIs6yBOBvH9AnnC-QLcz*`4_ zxK{d&gV=8iCXI7d0jj}kq76JeMXMmI+=ufL04{+%E~b&OTp^k*bAgYRt%~d?-e3yZ zRn;Z{`H(^kJBC{Hrz7_KEpu#Aw}7idqld;FnYr6QU=m_+32k>l0^`57C#UVxd7wCwM=Y_X+FW z``BLRD2cB6f|8O?t<{^4#Dm5n7Zz8)u5oGXFi9kcUc;{IXaRrZ**)Y`4hVd`yB!Hj z0HECKie{CeOXJOR^%nBayZy_JDa(2$Iy0MxqLI(L4X=tfg)cP9JYJT?pD1sBo6VRq z;!jtf>EIhk-In)cZ9MckvU_Jp(C3aDU0V5OOn2dwtBW_--={TLPG57+Z`_bEcxt^V zR?b(`f1W+!W;aY!zkDiM0C_=TCxbN_;@^4uI+JpUzXHS&qF%Z@d!*XRmscD2ab*LbxAQotj@?+ulBvO-=J32~6BV z^`7gXpF6xL1*a_OD2gj8iknl9>MNmVXBWHh`iPwvwC&F6cjQ+wtbR1a-9#j3D<8r< z&W`cL+}KIc%kR(96i$fXPbc(@jzl=Qn#X^XUQgtc)7P!;Nb4q4v{o)xxo=>$X02D~ zU#jHDzOam*b5K`T8``GsMdV>u9Phx;4o&3W5`V{0NQnf#BXQc?M z`!#ixmwLP6+?Y~MxaXJ329Ew%E^-XVqx() zg~a$)E-$xKa-#?|`s@#7+uECUi(%=#hQ#{d)9KqYs|`J78X;M7E@(-GCaJ;#=$4sF z&0l$GL$~g>;W87T71kMthMKzRdC`1=_nqf*S0;weto(GZCH_Nr-*|J^#7-SrTW0%k zjr_)WFOf8SQJh1c8>pL)I<@*f3SB9%rCohxOFUmvCgjZZP@3pgo5lzEcYHUHZ4Rae zu5p#FiS48)s<6)?#nFkpVmy@0pPQ3eMVyC%KXl&8Y=X-r4RbDNKh8JK9F43r#l`*$hGU8m literal 0 HcmV?d00001 diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1b45462ced6be11a40306ed170b0bb56f8240532 GIT binary patch literal 14418 zcma*Obx<6^xA;4|EU@_EmcZh{HNnH;5-dOw4@7?>yt?8aVbxwckbl1%3>N8Uvs;nrDg-(VJ005S(jD*U+*5^MJit_K?{nlXc zUjsLiQBeQ@H~N42006lAk9`*aoHzhrUmpMjk^z9&F0JXk&_6?v$wygJ1qFcVp9}>c zARGYvPs03H--5XQk4*4SmWxLEFZok6P?6IPBltXy1b|8dMIz{$bP!6CrGA>X1U|NjB3ZH+BVUH?14 z@%sM;avX*S{sFK4%Y&nZwTXkHfwkTLkKyJM;Nt&(oVYj<{$r^A!fca$h?9{ zeq`+wF}i^fJ)7x|6dgf z8m(}U2(1&+FOVHzs&Q{X;dJ+$2mHECl46&Ee{<&z9k?I)6Cs@=%j7KJh4mA(kJm&{qEms*flQ zYdS!vag+0k9{6w`WXs)f*Oy$ugbdxpFnq`tz<@H7pK%=qfV}1iOpSt<(3N96779TN zxdw$?xd2DjJO;7OkWRLi$W_g*iWe-Mt9pUv^w%C*BV;qwhSvMVmzUnf1P6u!y=IT7tY$@;`*2a@>u zf+1w>4+EAs!zT(#GFZy)!|y=Q)0|>>f)+dEnQ(2!_$4Y@B@y{3|04Uv6=%j}m&_Y= zd2d=MtV;qFdd^wG z%i+Ar7T_fgMRZpX94=gnmnIIvBbDT&uH=m`Hpb9S~24o}~ZCi4 zLlz!P2#~h=iubtX54}3k3Gd~;JMXI7;{`3?f?(A-ig7m=C_;vNE-?ZxyRzSpy>psu zbk28oceZJ}=?b3^ZWZ=S4n93-`d=NdDb&I#nGddhqz#Fb@{%}3;cT|UjcCfl)iZu-ONx<;1v1$($7Jvs)> z?(di-&JmY@z-s_a8Ouj?*k14rk<_eS%JK2rt+{t>KW3*Rg@fsFN7$(|hpFRav-6|l zB9g#>n!j=niPW5@2S06`9s-(b3NV)5Qe-lpWz%iILoq_%9Ch4~1J?93jgy%X6KYKWhBQc?Y=FgM7gms2xA|=C6xm%&gl^!K6!d3c^OT zP{JaD&zN;qFAV6@gG70})A2A8m_8&i*@5VM(Z)Af$BnOIfsQYR%|~-yw?AP}1IVXr zjN9tW4Zr!l^jf`7&sfm1c*j{vSb@P{qKxO^a`OVsa&Au1o3XSCtnJ*=EQYm19Vo}L z_hFr*fqn-W&Bs?792!ib>0$}VdL@Q!I)OB>*kr9&f7Ui%Oz)u|T>H;A$9_b6d3eIt zvF$p(nD>>M@dwPegM|50+o0Qlbag;32QPWmSm0Dp>8VtG=@p0b*n1PCWu7Rl#Q$gMCceCKhVbJXQd|G7_rtG(h?o+Z|vJlS`7lt?MSvImpN zq*ABbun|Q-uHS9N3qSO&r3gK%U?m;oBQ?1`tU>zpQC6-iqAv@{h8+RbUc8^%avx`B!^dMp zNk+qK*$~q=l7ETy90O}T_Nc-*zUy`#17sP(W zB3|J6Vnl9m$5kwR2zmsyn7)jVTmx5=8OxxxRA>sDkJs> z*+NLZ&ChCPZy`^07XJ|>KU>^o8-o@ z%l?fXaU8J|))QmkYQEyElkMtD_bi!uT`|SigC0w~N0d59pbPct|tqN zauC;3snA$UPY|al)~~+7gVvorm%`p$TD}Q zu&P9EBpDIv;l@QVZ|dVUAi!>&r67owIC2-92Tr7eyvM8lHvC4e>#I>Yjb96uW}WP+x?XT+Z%D@d3FbRMOQ`Bb7m#c5m@0?y1wGA#ZINJ)D1mXe0PLX#6Vu>!MMiBa3DL zbq>txxq(r7@LVOJ+~sYw)-s*G({aXI=C)>0FDODtC+yehXN5jGss%d#Iii?uamITH ziH*B|UXs_?fH$PmqW)c?XUvh`gpNSKS@epa0eC#e@%<0UtQ0qirGRr8m+bwo80{z|!h zYe>-W^IgwdX3D5+J*i?tUno!@)+2;`5TvN-+;w$SXr-=8-~YW48O_gbk9baPo%ucf zq#l1_`@<6}QtSC4YD^jph0XbMB1zO>o1)l+g^M_ia=X3X&~U3_&yv)iWy}&q@OqIs z5bDJ1n-uf#uy*xwC;jw+xB)&%7u=suSAZJAZN0(w^gF)fFrMTaLtrk8>Q>ce`O%_P zyGp+}#qDkCY~q{VNeM44OYb~7$tr#qa^B*jV}6{?9YhNZN|mCGrC#z4B?Rv)!KuWL z3N(oVT*LBGS$SEHV&jXHX9@oz?1+-ySF|+rUpi!bWvaosTO@3}q}r~&>DQY3!nFCX zU<}ta{ORMKxW&GN#tdidaqm>LvcXU_P_w}CTG3L16oBQ+CyNn!Mm(AY;W;uE)21)O z$i5`oZ*LCQDhJ8Q2_T&W#PyhR5kTeB7>#pTEt6VcdycFN96npu$OHix;OKzQQ@BeG z#PbEx`wVfCmOBH6)tY1nl1xq(N1(hT5oU9;9k24}-W=ItY5kWTX5_0H2|4-t{I-Tq)2afc((s7 z9(TLWM#mhxU+Oav@K9MyRIaQb+o-_~^SsfH7)RVV16^0wzJh3oY3L~HEb2zx~<5DEH3u3@bM0<5rMXNv;2w(i)R{lf!>t z05AkFkL>Y#eb4kB9z4=ycwzgc53WEhPF)&S0w}Gxws@?kdt0*e??F0OmiM2J*_=cH zT~Laa1+w{G)-&P_;FGww^geGqC7`t7ErN;c`r+FbeaZ~y?ZSsS?WZ1!dVBe@#yhRH z6Jbj9{CqPDTD;hm6*kwC`=@5lnY^Tw(f0M}?e@pa@<5G|5f=dtsL`fqz85v*8#?32 zZZ(&b$lnwVS8ZwO&HYTht(EH)oB(ca2aXy14~F`>?JXzeE7t6dn}+AN&Mm!dkKE6L zk83l6Q@kki_)1(gZ?s$jUYZ8+#Prrxauj!medwY>t`uG-^WQ$ZvLd%2^IX56ow+8s zS?7JK!W-DcOx$of68(8PC;isXK805pE|Rf!=qn|8tbJhTHu6XMa$atW_Iw{K zLfTZ{7TNFp=V0#6`B{~V=AgH$qr>o|&A#n?boIdI>5_GcN67rCKeJ`x0u6V``NV(5 z&#_GWU1U#xl)4fiu#{1Ui1d*)(TgoR+1`nHjk1&O_M6$9+*a9j&G}+($4|v~*vM8nKwP(FTiLoc)wjewEkWetTOL zTZWpgE+ng8LReqn)9w9;OEX+Mkd-Ht6C7i5Wy0c!rpyRIqSVoenCwY?-?iKG;Wx&| zbaS|F?q3;Myiz=?mmjGHnXobF<+}KLRcNX%9b!d%9qfGzYO?KcU7oWHGQR3iH)%Lp z_jxm?QwC8Lpta#@-kv=#d|7}h=Mchg(;y|Fu4e?FPj;UT4mog8#0KqxqXXI&ToGWt z(7uESWBR1Q0o4&f?W#S@A9B81M6@8|35zuM70uYu9eqO#cYbh1K+0bb3FoWLbH0-Y z2L;FSy2Fld<*-J1vYm`;M@nE>lV>~#N`gDj@K7qVEQrG6Kp$gR_i1e%1P zu^QB5o;BCZ1hxH%!bLBK;ghy21$NbP!}G+*q0K5~3Fh8;1IbTQCeb)ZOQ)&L(d-1L zPdF{J8E2cSbjM4RRFKNI>$3S)N!Q+M?AA)Qami3Y5 z`RPd-%A1b+7KU{DFj+hTf%mPupBOUTz$?$b?KOdAHV+$Dg5#3GgH4bfh>0aNrGctJ*~f=QT-UbRzjWpyhd+mlJHtNw1*`eOV0OefFB?qHIy$>C+^@O>f-{cU zK92fB?L+mt`Ug=r!^LMXn*~-M1KH1uv~BwD#^0aMw?C`x`-Nv1MgUY|^xi&y8F$hR z-rnCT<-M_4FfY5W7rae+UajZXF5|MetW#0uc>e&-`dGLwyn6HP(|xL7qk)yb$CX3o zUpAM8MryGjXv#Gn)u`a}!1l@06aLCvUu45^-RcH=`^^W;&#&}e$bD}wJxnrtL!>Vw z`lGK4X!_`vNZg-3S6sM+1>WaX4|-7XW$Uw z2{Ejs-M_!lzxC1CO_G4@a#3q25?z|LH--z=Tg5vlddH@DKQ(Tj_87b*d(lzHnp)|4 zPNFFIC~@1v@Z2YOyBRlBTmPKseV5=p9y+ejH@LNt1shxU56hw>sl2sd{XO6E@b_n` z{l*$FB7DSi9-p6KghTnZXFZ$BYOzVP&T2k{dsIN-?ODBdy1>~c{_yMi_hE4``k>n<7Jd2A$W}e$ltx9;`Z0 z7R?@=kMh53`#b#{H}S@KAn-sf^`R!ciHkp8^%*aL;yD z2im6Le0{^3Q>WrC6#aw)FchiYM)jiV$UGKPg4#DAx)$YRzG~#S@OGOLXSliE^KbR~ zlHoii&IJbU{4Tr~%JH7%>`|mGv6{S&ggq(B3K_&>i90FBk#u$WA}f-Fx(J5%iH+e8 z4f*hv#zS&%FW2n#rIc&*K^QV~dOZOo^+5U_g&{mMJD1x(6m=a7Nm{YZTGARo ztfoj;F16`^lb*QJVPh)D#P^b+($yYb@5@D|OC~t1IE=GBs3wRu)JV9q-}&2ITr(FK zFc{jkmp#*(RIrxFJAS`_ho=BH$U$$Kwx(|=0ilpaYCZSk&+Hj3)%2CEtB76{gyc)gwJ@_Mt%qeK~ zzAOg=zWD7jx0#%tk*5}gMV(aVOaYbC@Qv;3H*v4g1it?^Hc%2-#6u_4CjqtbIh0#D zVcCOo;k(xm1MLy}l;46A$WN-45;a1S%7r7oBhx@UFoMY z`T$R%pY0k%BIkV^s4F!f?gdTdQT=T=`GY$G#0 z$eI9q`@i(qgd72(gaAfAXCB9k^Yhy;0>eUl3%KV!q^OAWMlc83jZx0fQ+K3xXc%AM zgB@bKlFr3ng`twq#*`Ci5%~O6jvk_H8*;#qoDsRK9~mCvP!y(Dqw@FPXgyNT)48d{qidZN!BOdtdyO9D(@37-w3`)d8+FfBFh zhSeu?d+qHDyoH84)WQCZt{o69&l~Ih~*E#?6W!* zl?SP%{r#HN20gT_Rd{WJ)4x<$kO01ZBhC*8EUA? z>qPIccCVzN6 z5g0hMY$L)W>?}%>hrHErXREcFL*zP zjF^9^OSdVhtY$ z)#RHe5q3(64?dQ-H~DhOE8AGunUG&Ze!rz2Jy;2g;U@(8Ggd+`W3Se~t~xnF5!U_o zXHXb84SU*FCOeur&9#oiXo{81igEQcyu9HlvgT%a0^PhCA9lhguDr`jqNwm*O+=`| z`dp){H^q`Zaw4j|a>JS~2{{Ba@|N7CFuaN(!>}de6J6+UTbt&k1macbuXj*~ycfJ= z0|(>O0B6w~^uOP~+T9F%vem1f7158pgsa16v~XRhe}&iLSlqt3YsT%pzxMQh0Tujr^H2)-ttcLwlz1)F|TL6?)wTC)7T*`-s^OmEuPn$5J<~szJ6}{LoUvO)=QM#E6$ZFHu=jOZ>oM%h*kyf~A}lHeu_Gbgg?1H-Z8e zfj552wx`!8o|4IfwlcOWzo)iKPv2NyG4VeA9VXBuNZvtw1A}DrZop7LPfPp-ziwsV zk=WGrAqsOiKBLi-R-~?-2gy-d+a;@V6Nqz%Z%c#t>hPVBxmwO;jpj7bQy@;Z7jw-`BMAA_qws# zPV$%j3i%62AK*FZQ7z}0*dd}f;hB%kt~b!v2xILnE(>kvUm?K8_uM&9LF?4UvsSp^fPbth?UI?`e-YU(d=`ciE0qCvvi98a5z*w-f%e!_}F=X z5LjGwxbPB^9>1xQ_`EZgwAWyhk(BryzcB4iTh9jwJml-x3k)?8L-%`Ux48mZ>nK7)$RopuNe!nCjz3IJ$I<@k zi^iha4&^Y1aQr0pq6rWd>B7_@=@NM)^;vZh^dUxaj1kGJ@az`;j;~m?WhMI2x`$q^ zIHHDdLN{aKQXlVZEMiBT;$2m1Dm7zi@4zO;d$1-+NUkbrDa)s_F%1wpRG9NhTSMr+ zy`^6ryF26h1>jR0Pu+}*zPE2EQL0>t(06ysiVPx*;g0x~Orq_QJLE0_j)oV)BoywZ zB@@}P5b*Rt!+uF(<>Ki`cbk@5qoM?NSd%Sy*F4I?JZArRg~~4u3U@>oKusO)I@ni7 zmY6aaxEuW@49u$s%yw2Nimu_VN}%4jGvTvL%bf|v$@%YLD!f76#>k<5QERUUrgDa0 zs2ask6)DV$Wibxq>ZSe8b+&0-YvCLqcI-3Vc=9Iz)8>?*L@TN{T>oJL6r4i%Qk1NJ zDuR8I6bJkM)J^KW!ai!gt(!&2i%aS;6Xb^O-#$B~CbDddvGFwNetS#CZ1NRA@4Yfp*z3&?P&V&x$(46ZuaPfT1QfY4bGRIS; zSW=rj@AO>l{CJbEA@#A!;;G~n>AV%!jA^iQ#@d7@63g)djW4AH>+X-5zhC*aZ&8J9 zz|pyVJ*UBUSXnA4Zf^Q;s2}W_k}o12_t@h&@0F5#uJh{Y;2$2$6k?g&In(N)i%T)_ z;3-4h61i&kaIS?k&4|b^H;JV7)^D6I>hSXv4Bg)?H3xV8Sv5+g+fyYd{d4xz+b1=e z#za87IElf&MU^momF!sLYylf|p{RR%sL4yzABKyeVp1*-~&aWRHYFja-OEqFTI#DeQj&~qu4~?<{ z)I5(!=}g>S2S&!AVLm14hB}2E@d$|@F}a2ewVI_Z zN-U|9M?d0?p$tqNaP)Ed=D1(6tu!0dL;nP6s8*7BE67la^Egf}&%tw86s%=Ev2|d`cr6zbd=U{9xp|@)t z&Ab#PcjA5Rdv0F1VNt9d%xcA+r3v+p)8W3IDlIiQBr;ZUVvopJDnZ>BJ;kV!z+R2X z(H_?(8^UWZvvi^6FM3K5_5fuoQH2PdcC@DkN68?${As2F!4CO2tKK2C|JT<&_<) zOo1MQih5J?7ucWmF~`)W#DtnH6{T#_4_0^AGh`Q6n}OL#4=CxB9YjrtlthW%_TfNm ztQ~{p8P?c33P-n;q3wcY;VfLPOniw7*CnkowC}KYLqy=QS3jWYpY?bB6XZIw#$ln5 zDPayQm#C7uofwcP>NOY`l4U^ZNcXu#x{EyTDlEKq>F#EXvwE1C#NecVDz)DB*hGy1 zYIINzE_ZcvaJ8T9sauN*n&$L%$eUa=fJP=W*oUrJdF zOF20@RNG-u{;a30sq6Ks^Pr;29+3?CeAphHJ9W@CuUQlVPo+Ue<{WSdkwGnzMSNJ7 z!_co4Yf@eslJDqv&H~xOPT62%q2BHiL*6~$F5*d|Gg<;m+ruJE?#uNr zR|_=Bnm4HpWiA6)5FZC{PjO>%vNfN~?A8zgd}XVM z)WOcHD~ys}xqUudodTqtodFJlqa*)HR;U;N5e@MSHcR&FEoAqKVNp~%^CkDj8rY%} z7DC7MWgh9yOfj~!Bl)CMf1|1zU=~A00)tTP={V{+UD_F}^EOo|OYYu-NBj{kTkRCO z;OOXD3U~`){NTspqM&v4cD0A^h&~`}z?sTnh?A#~z)A`P-9@#_qs}ca=db=3{xeLw zq@;;Y!`jBCtD@?^KakZ~=!Q1+6giA(5tdAEl%#AC>G6nb*MT2Vlv%@5Gja>M9Iqw1 zrW|^LB8N}J2+gNDEOLY-(DP0n2l#%c#8ft4388xF0A#Hgb2prvfUE9evc2W&PI(raU!# zZ~-b^4g#Vbc8)$5u@LQ7U^a;&Z@`XfNemZU<^ISqSx7*ZJH#5l>&Joe&ggAO^!{3y zWWkB_ptO5*#kp^+hM&23a8IKpW5_SC?e=w@SUW4Ea@muX5K~J%GW&9L`~|rjNl3z`1s&BYU2Drc{r8am0)3 zddUTUs?iwt9(y1)wE=I>-zG`;S<}v7H(JiWPsp5U{yj6$iPKKe%C|niX)8P$^kmR@ zdlkjR1cP;EJ}0D*+q%(3TxUkUHl0)*k-WP(;%iY}-!Kd$Yxp{p6_}#h>Gm>LMYlkU zE%1UIY=Um@Le=@aAk>K6CS~a*8$xWZO}2p5`B9FVIJP9-6HB}RLsd%HUrJbvz0{YA zWEv$L3K50beW?qv)bRcOQm`_xhC%9+N#s&TmEdP6|u0sdJ%DqC5{6xYZ)H1~dS?;psO+BGA(p?`Sq@I*t>( z>pDtkgn~~K9x|h1%Js`;MgA41Aiw|EHU6&fx`GyI)Jz)&vZ#!+xsHx~n}LqY zhn6HqEZQ_CnwLY0?#gIvZ0I~+iM7dF7}M}s*!&uW%;l@wDPRA~Pwz?5;6Lf5%4RHk z1y;+&r;`;@r=#_AuuUBh&N$JE(r6`2(IK$Ax1SjYpCZX4Yl9kgo+e}D(%GaItnh%% z5{}COOeZ0`*T@t$+Y|3;0WW$S;DF3f#0mvfQRZk)v!XCwP8u`Hd=q1?_8uPLqt?~4 z*JRE5r{Le(;DGmn3^i@y-~?4StLfw4bw9EQk_PjMz!8JzY*Lq7CZ4HpkSfy=KLK-s z`7@f_an~YjDWmLA!Vu*GgAk-()2vIkDIqRDZec$#@l1_Q5jUoA80Y#F0UTu|h*RG< zpyFrkJPQa{u2!WRsWu3V;NbaveD1ap{22+H&dlR=-K;b#cYaN_k?tcevJQdIWb*^W z;hc*tPU4o_q!wVmqeX5(Nfq?(62H(8o|Ks22211;^{ndZEDVD$n!8I&xdrZm9l|re z;vHT@o*yZ*&93p|POfv}tD)}PPx=1|@-kWo(J2F#ZaSkl<6d_D946R7jJ}hQD){{+ z54&`BLkAxhiHw+szUN&#*~rU-pY$T%ocz1jmss>!4m*4r=1Dm+gL5Z2_T-l^rG9k1 z{{RZ8YbO8M+^j1Wmn_$0KPs$*Y>eOY_UGlFsP90HojYMq^*2YKKHb-ni9|+pix3)v zw$w~Q?-L61ysnEmdShN&Tvk>YyIoj~)@3B;Z0DS~C<*^jj6pu|T5evRJ^{pY`o8%O zc7YzMoMhJfa?<_RWm=#X$H-g1-6>ip8|~nfHUJa{Q}q0!SQ+RcthxZ z-*Q*WD4e742{e~MQ8kV*I)ediHuV}T%lPf&yxqW$@c|+>Q-HjP3 zi)n$gst>_yaz%?2Bl5!E1I1>pE~Q)dHX_=LNuV5Od!p27DUfu3guX$k7g`4-=K2K~5h73avs~o&3#~BnXvA9mCskpm>|hxcmK+W&*a_CuQiXclp=w;R1$nH$G*|CNOL(UfH&9?flH=O%m8Mc4=*u$862@N0r^( zpx)*Ep>~FZ8ezrZiULgtJ4&hjOt!?qzo)RB72x1tG{=T}RNL_4V z#iyu#NuU1BH-|*Tcq*A&_>F0oVI}U!=u^tnJwz+~Lmy6d)3UL{j+^JBYXj_e13DuJ z9x3W(EbPD)U#2+vwz{&SVlY>QH#lLQG49iKQ`vD@h1w^NvSaO*F5@bCVIIs%qV)>` zol-VK%*a{Mj8tsDt^;g`f!ML`oRH5>cqg zua~f94&<#DXme>^Tg6+8s%jw-KtC%N+$l>3fx6E8J9={GR=iS5A{AzSW!MIcgm79Y zPpZXo*uVVjGgZghE<3&Ny@vc&Cr5OLpmF86f=}1&Q-$qGeXTOAjMA63*`H(Kj4dt2 zk2&c>0VR3vb7WZ3r96qrNMhO3I=(7^XQ)PlPCwh>x6+Dbg_6lFCVGA6sc@G`j8gH)Mk@ZZawc#lbhY?Ddw7M`k4n!#Q{oT zZAz2jc;U%C7@4p@_k40U4I=|dEBo#ep8OqM!5N26j{`>XLsN>$0Jhe#uBn8vTw~ms zK$ICIWu%3`m=0UHZ`OXyXbWO>^feTvc{RcYMhOnQL9?7zYp~Vxlcka^`t~nq%=zAa zu1*QM@kpzSc4fgDGKqKJqx8>`B~7ggv2&7^V#*-1K7>VualXkCDD}D?Jk4;ks8Nf0 zfyC>5>KO7!2GQ;zF+-K6b^lvPnmRy&>wo3dA;tZqL0@+jol8Ypi^9gM zZXNq&OXT!#wnB|nv|qU^j+e25FYq6CT8scYnTTtHf$gYwQPu*J*d>GCo%|~rb$C=4 zRTM}7);bLM-p$E~5BR(O;QrLNgf(%wYjA8x$YX#Z$clk7qu;OTkYh zR$LgpHP|K9h3l6b8IcAXe0jW}TD9z6>bs2;G0Kjp)m>=1M^>vx%s~t?` zRZo_@HVp%fVc;|`%ukgR`hdOGh(q0ir^*6qG-sN(QhMj3Lj8ck7BhhUThB2EC6pu{*%RaU*})FWT*$Ck71nE3W8k8#L6~K|9|t z^7GmB1wKTZsyM{{c=f|qF9@TI@U)^}L}VhRwcp7+>g#X4D^wWh?fIeE#`EKdQXSMj z64y%C0?lX52gFV)dFCsb^zR$M{Vk5ys9+le0XQ7Wezfquzckn;x Z%?2A)5h Date: Mon, 22 Jun 2026 17:03:44 +0200 Subject: [PATCH 86/98] docs(site): add landing page, top navigation and API intro --- docs/index.md | 16 ++++++++++++++++ docs/toc.yml | 6 ++++++ 2 files changed, 22 insertions(+) create mode 100644 docs/index.md create mode 100644 docs/toc.yml diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..5e765d5f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,16 @@ +--- +_layout: landing +--- + +

+ SquidStd +

+ +# SquidStd + +A modular .NET toolkit: foundational service contracts and utilities, a DryIoc-based service stack, +networking, plugins, data access, messaging, and Lua scripting — published as focused NuGet packages. + +- **[Getting started](articles/getting-started.md)** — install and bootstrap the core services. +- **[API reference](api/index.md)** — the full type/member documentation. +- **Packages** — see the per-package guides under [Articles](articles/getting-started.md). diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 00000000..019419e8 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,6 @@ +- name: Home + href: index.md +- name: Articles + href: articles/ +- name: API + href: api/ From 4160401e93b2ff288c62c4ca312b876294599afa Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:04:30 +0200 Subject: [PATCH 87/98] docs(site): add API reference intro page --- docs/api/index.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/api/index.md diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..f94ad68c --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,5 @@ +# API Reference + +Auto-generated reference for all published SquidStd packages, grouped by namespace. Use the sidebar to +browse types and members. Each package's purpose and a usage example are in its +[article](../articles/getting-started.md). From 0086ac946b18b7a826a9f831800a51001848d2f3 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:05:25 +0200 Subject: [PATCH 88/98] docs(site): add custom teal theme from the logo palette --- docs/templates/squidstd/public/main.css | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/templates/squidstd/public/main.css diff --git a/docs/templates/squidstd/public/main.css b/docs/templates/squidstd/public/main.css new file mode 100644 index 00000000..f98ddc0c --- /dev/null +++ b/docs/templates/squidstd/public/main.css @@ -0,0 +1,54 @@ +/* SquidStd theme — palette derived from the logo. */ +:root { + --sqd-brand: #1390A3; + --sqd-brand-dark: #0F404C; + --sqd-brand-deep: #0D7786; + --sqd-accent: #4BB4BD; + --sqd-warm: #E3BEBB; + --sqd-ink: #0B1418; + + --bs-primary: var(--sqd-brand); + --bs-primary-rgb: 19, 144, 163; + --bs-link-color: var(--sqd-brand-deep); + --bs-link-hover-color: var(--sqd-brand); +} + +[data-bs-theme="dark"] { + --bs-primary: var(--sqd-accent); + --bs-primary-rgb: 75, 180, 189; + --bs-link-color: var(--sqd-accent); + --bs-link-hover-color: #7fd0d7; + --bs-body-bg: var(--sqd-ink); +} + +/* Navbar branded in brand-dark with light contrast. */ +.navbar { + background-color: var(--sqd-brand-dark) !important; + border-bottom: 2px solid var(--sqd-brand); +} +.navbar .navbar-brand, +.navbar .nav-link, +.navbar .navbar-nav .nav-link.active { + color: #eaf6f8 !important; +} +.navbar .nav-link:hover { color: var(--sqd-accent) !important; } + +/* Buttons / primary accents. */ +.btn-primary { + --bs-btn-bg: var(--sqd-brand); + --bs-btn-border-color: var(--sqd-brand); + --bs-btn-hover-bg: var(--sqd-brand-deep); + --bs-btn-hover-border-color: var(--sqd-brand-deep); + --bs-btn-active-bg: var(--sqd-brand-deep); +} + +/* Active TOC item + focus ring. */ +.toc .nav-link.active { color: var(--sqd-brand) !important; } +:focus-visible { outline-color: var(--sqd-accent); } + +/* Inline code accent + table header tint. */ +:not(pre) > code { color: var(--sqd-brand-deep); } +table > thead { background-color: rgba(75, 180, 189, 0.12); } + +/* Affix (right-hand "In this article") active marker. */ +.affix ul li.active > a { color: var(--sqd-brand); border-left-color: var(--sqd-brand); } From 055daa2d7f71a94af30f0f04400d01447268d075 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:06:48 +0200 Subject: [PATCH 89/98] docs(site): add getting-started and per-package articles (reusing READMEs) --- docs/articles/abstractions.md | 1 + docs/articles/aspnetcore.md | 1 + docs/articles/core.md | 1 + docs/articles/database-abstractions.md | 1 + docs/articles/database.md | 1 + docs/articles/getting-started.md | 20 +++++++++++++++++++ docs/articles/messaging-abstractions.md | 1 + docs/articles/messaging-rabbitmq.md | 1 + docs/articles/messaging.md | 1 + docs/articles/network.md | 1 + docs/articles/plugin-abstractions.md | 1 + docs/articles/scripting-lua.md | 1 + docs/articles/services-core.md | 1 + docs/articles/toc.yml | 26 +++++++++++++++++++++++++ 14 files changed, 58 insertions(+) create mode 100644 docs/articles/abstractions.md create mode 100644 docs/articles/aspnetcore.md create mode 100644 docs/articles/core.md create mode 100644 docs/articles/database-abstractions.md create mode 100644 docs/articles/database.md create mode 100644 docs/articles/getting-started.md create mode 100644 docs/articles/messaging-abstractions.md create mode 100644 docs/articles/messaging-rabbitmq.md create mode 100644 docs/articles/messaging.md create mode 100644 docs/articles/network.md create mode 100644 docs/articles/plugin-abstractions.md create mode 100644 docs/articles/scripting-lua.md create mode 100644 docs/articles/services-core.md create mode 100644 docs/articles/toc.yml diff --git a/docs/articles/abstractions.md b/docs/articles/abstractions.md new file mode 100644 index 00000000..bf188aec --- /dev/null +++ b/docs/articles/abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Abstractions/README.md)] diff --git a/docs/articles/aspnetcore.md b/docs/articles/aspnetcore.md new file mode 100644 index 00000000..63458816 --- /dev/null +++ b/docs/articles/aspnetcore.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.AspNetCore/README.md)] diff --git a/docs/articles/core.md b/docs/articles/core.md new file mode 100644 index 00000000..1c84ca7f --- /dev/null +++ b/docs/articles/core.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Core/README.md)] diff --git a/docs/articles/database-abstractions.md b/docs/articles/database-abstractions.md new file mode 100644 index 00000000..2bc73ad3 --- /dev/null +++ b/docs/articles/database-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Database.Abstractions/README.md)] diff --git a/docs/articles/database.md b/docs/articles/database.md new file mode 100644 index 00000000..b739dab6 --- /dev/null +++ b/docs/articles/database.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Database/README.md)] diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md new file mode 100644 index 00000000..88533d39 --- /dev/null +++ b/docs/articles/getting-started.md @@ -0,0 +1,20 @@ +# Getting Started + +Install the package(s) you need and bootstrap the core services. + +```bash +dotnet add package SquidStd.Services.Core +``` + +```csharp +using DryIoc; +using SquidStd.Services.Core.Extensions; + +var container = new Container(); + +// config manager + event bus + jobs + timer wheel + dispatcher + metrics + storage + secrets +container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory()); +``` + +From here, add focused packages as needed — see the per-package guides in the sidebar and the +[API reference](../api/index.md). diff --git a/docs/articles/messaging-abstractions.md b/docs/articles/messaging-abstractions.md new file mode 100644 index 00000000..7868e11e --- /dev/null +++ b/docs/articles/messaging-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Messaging.Abstractions/README.md)] diff --git a/docs/articles/messaging-rabbitmq.md b/docs/articles/messaging-rabbitmq.md new file mode 100644 index 00000000..1f0e3f35 --- /dev/null +++ b/docs/articles/messaging-rabbitmq.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Messaging.RabbitMq/README.md)] diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md new file mode 100644 index 00000000..070fedeb --- /dev/null +++ b/docs/articles/messaging.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Messaging/README.md)] diff --git a/docs/articles/network.md b/docs/articles/network.md new file mode 100644 index 00000000..9d4f9475 --- /dev/null +++ b/docs/articles/network.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Network/README.md)] diff --git a/docs/articles/plugin-abstractions.md b/docs/articles/plugin-abstractions.md new file mode 100644 index 00000000..63250ddb --- /dev/null +++ b/docs/articles/plugin-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Plugin.Abstractions/README.md)] diff --git a/docs/articles/scripting-lua.md b/docs/articles/scripting-lua.md new file mode 100644 index 00000000..38a403f6 --- /dev/null +++ b/docs/articles/scripting-lua.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Scripting.Lua/README.md)] diff --git a/docs/articles/services-core.md b/docs/articles/services-core.md new file mode 100644 index 00000000..0079679d --- /dev/null +++ b/docs/articles/services-core.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Services.Core/README.md)] diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml new file mode 100644 index 00000000..e278526b --- /dev/null +++ b/docs/articles/toc.yml @@ -0,0 +1,26 @@ +- name: Getting Started + href: getting-started.md +- name: SquidStd.Core + href: core.md +- name: SquidStd.Abstractions + href: abstractions.md +- name: SquidStd.Services.Core + href: services-core.md +- name: SquidStd.AspNetCore + href: aspnetcore.md +- name: SquidStd.Network + href: network.md +- name: SquidStd.Plugin.Abstractions + href: plugin-abstractions.md +- name: SquidStd.Database.Abstractions + href: database-abstractions.md +- name: SquidStd.Database + href: database.md +- name: SquidStd.Messaging.Abstractions + href: messaging-abstractions.md +- name: SquidStd.Messaging + href: messaging.md +- name: SquidStd.Messaging.RabbitMq + href: messaging-rabbitmq.md +- name: SquidStd.Scripting.Lua + href: scripting-lua.md From c2792e1a6200736c91af46548ab4bedb1b0d37fc Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:07:31 +0200 Subject: [PATCH 90/98] ci(docs): build and deploy DocFX site to GitHub Pages on main --- .github/workflows/docs.yml | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..f4011b47 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,47 @@ +name: Docs + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Install DocFX + run: dotnet tool update -g docfx + + - name: Build site + run: docfx docs/docfx.json + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 From 2e904378a28c6391449a58af6a37b709a29de2df Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:07:42 +0200 Subject: [PATCH 91/98] docs(caching): add NuGet package READMEs for the cache module - add README.md to Caching.Abstractions, Caching and Caching.Redis (auto-packed via Directory.Build.props) - list the three cache packages in the root packages index - rename the root README title/logo alt from SquidStd to squid-std --- README.md | 7 ++- src/SquidStd.Caching.Abstractions/README.md | 55 ++++++++++++++++++++ src/SquidStd.Caching.Redis/README.md | 56 +++++++++++++++++++++ src/SquidStd.Caching/README.md | 55 ++++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/SquidStd.Caching.Abstractions/README.md create mode 100644 src/SquidStd.Caching.Redis/README.md create mode 100644 src/SquidStd.Caching/README.md diff --git a/README.md b/README.md index a5da3cbf..b5517b50 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- SquidStd + squid-std

-

SquidStd

+

squid-std

CI @@ -30,6 +30,9 @@ Short description goes here. | `SquidStd.Messaging.Abstractions` | Messaging contracts (`IMessageQueue`, `IQueueProvider`, serializer/metrics, listeners). | [readme](src/SquidStd.Messaging.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Messaging.Abstractions/) | | `SquidStd.Messaging` | In-memory messaging transport (`AddInMemoryMessaging`). | [readme](src/SquidStd.Messaging/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.svg)](https://www.nuget.org/packages/SquidStd.Messaging/) | | `SquidStd.Messaging.RabbitMq` | RabbitMQ messaging transport (`AddRabbitMqMessaging`). | [readme](src/SquidStd.Messaging.RabbitMq/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.RabbitMq.svg)](https://www.nuget.org/packages/SquidStd.Messaging.RabbitMq/) | +| `SquidStd.Caching.Abstractions` | Caching contracts (`ICacheService`, `ICacheProvider`, `CacheService` facade, metrics, connection string). | [readme](src/SquidStd.Caching.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Caching.Abstractions/) | +| `SquidStd.Caching` | In-memory cache backend (`AddInMemoryCache`). | [readme](src/SquidStd.Caching/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.svg)](https://www.nuget.org/packages/SquidStd.Caching/) | +| `SquidStd.Caching.Redis` | Redis cache backend (`AddRedisCache`). | [readme](src/SquidStd.Caching.Redis/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.Redis.svg)](https://www.nuget.org/packages/SquidStd.Caching.Redis/) | | `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [readme](src/SquidStd.Scripting.Lua/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Scripting.Lua.svg)](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) | ## Build diff --git a/src/SquidStd.Caching.Abstractions/README.md b/src/SquidStd.Caching.Abstractions/README.md new file mode 100644 index 00000000..5adae69a --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/README.md @@ -0,0 +1,55 @@ +

+ SquidStd +

+ +

SquidStd.Caching.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Backend-agnostic caching contracts for SquidStd. It defines the typed cache-aside facade +(`ICacheService`), the low-level byte provider/metrics contracts, and the shared `CacheService` +facade that applies key-prefixing, default TTL and cache-aside once over any provider. Pick a +backend implementation (in-memory or Redis) from a companion package. + +## Install + +```bash +dotnet add package SquidStd.Caching.Abstractions +``` + +## Features + +- `ICacheService` — typed `GetAsync` / `SetAsync` / `RemoveAsync` / `ExistsAsync` / `GetOrSetAsync` facade. +- `ICacheProvider` — the raw byte-level backend contract implemented per provider. +- `CacheService` — shared facade that serializes values, applies the key prefix and default TTL, and implements cache-aside. +- `ICacheMetrics` (+ `CacheMetricsProvider`, `NoOpCacheMetrics`) — hit/miss/set/remove metrics. +- `CacheOptions` and `CacheConnectionString` — configuration and connection parsing. + +## Usage + +```csharp +using SquidStd.Caching.Abstractions.Interfaces; + +// Resolve ICacheService from a backend package (in-memory or Redis). +public Task GetOrComputeAsync(ICacheService cache) + => cache.GetOrSetAsync("answer", _ => Task.FromResult(42), TimeSpan.FromMinutes(5)); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `ICacheService` | Typed cache-aside facade. | +| `ICacheProvider` | Byte-level backend contract (per provider). | +| `CacheService` | Shared facade: serialization, key prefix, default TTL, cache-aside. | +| `ICacheMetrics` | Hit/miss/set/remove metrics sink. | +| `CacheOptions` | Default TTL and key prefix. | +| `CacheConnectionString` | `scheme://host[?params]` parsing into `CacheOptions`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Caching.Redis/README.md b/src/SquidStd.Caching.Redis/README.md new file mode 100644 index 00000000..7e100ece --- /dev/null +++ b/src/SquidStd.Caching.Redis/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Caching.Redis

+ +

+ NuGet + Downloads + license +

+ +Redis backend for SquidStd.Caching. Implements `ICacheProvider` on top of StackExchange.Redis, +so the same `ICacheService` API reads and writes a real Redis server with native key expiry. +Registered with a single `AddRedisCache(...)` call. + +## Install + +```bash +dotnet add package SquidStd.Caching.Redis +``` + +## Features + +- One-line registration: `container.AddRedisCache(connectionString)` or with `RedisCacheOptions`. +- Redis-backed `ICacheProvider` reusing the shared `ICacheService` facade and serializer. +- Native TTL via Redis key expiry (`SET ... EX`). +- Connection via a `redis://` connection string or an explicit StackExchange.Redis configuration string. +- Built-in hit/miss metrics via `CacheMetricsProvider`. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Redis.Extensions; + +var container = new Container(); +container.AddRedisCache("redis://localhost:6379?defaultTtlSeconds=300&keyPrefix=app:"); + +var cache = container.Resolve(); +await cache.SetAsync("user:1", new { Name = "squid" }, TimeSpan.FromMinutes(10)); +var user = await cache.GetAsync("user:1"); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RedisCacheRegistrationExtensions` | `AddRedisCache(...)` registration. | +| `RedisCacheProvider` | StackExchange.Redis-backed `ICacheProvider`. | +| `RedisCacheOptions` | StackExchange.Redis connection configuration. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Caching/README.md b/src/SquidStd.Caching/README.md new file mode 100644 index 00000000..cfaea647 --- /dev/null +++ b/src/SquidStd.Caching/README.md @@ -0,0 +1,55 @@ +

+ SquidStd +

+ +

SquidStd.Caching

+ +

+ NuGet + Downloads + license +

+ +In-memory backend for SquidStd.Caching. Provides an `IMemoryCache`-backed `ICacheProvider` with +absolute TTL and eviction, wired to the shared typed `ICacheService` facade — registered with a +single `AddInMemoryCache()` call. Ideal for single-process apps, tests, and local dev. + +## Install + +```bash +dotnet add package SquidStd.Caching +``` + +## Features + +- One-line registration: `container.AddInMemoryCache()` (provider, facade, serializer, metrics). +- `IMemoryCache`-backed storage with absolute per-entry TTL and built-in eviction. +- Reuses the shared `CacheService` facade (key prefix, default TTL, cache-aside). +- Built-in hit/miss metrics via `CacheMetricsProvider`. +- Configure via `CacheOptions` or a `memory://` connection string (`?defaultTtlSeconds=`, `?keyPrefix=`). + +## Usage + +```csharp +using DryIoc; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Extensions; + +var container = new Container(); +container.AddInMemoryCache("memory://localhost?defaultTtlSeconds=300&keyPrefix=app:"); + +var cache = container.Resolve(); +await cache.SetAsync("user:1", new { Name = "squid" }); +var user = await cache.GetAsync("user:1"); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `CacheRegistrationExtensions` | `AddInMemoryCache(...)` registration. | +| `InMemoryCacheProvider` | `IMemoryCache`-backed `ICacheProvider`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). From 2aef00be7008c35c724ffa8dae653c76dd20f1a4 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:08:43 +0200 Subject: [PATCH 92/98] docs: write a real Overview highlighting bundled utilities (hashing, config, serialization, ...) --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b5517b50..3beda388 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,21 @@ ## Overview -Short description goes here. +**squid-std** is a batteries-included standard library for .NET, distilled from years and years +of building real-world server software. Instead of re-solving the same problems on every project, +it bundles the foundations you reach for again and again behind small, well-defined contracts: + +- **Security & hashing** — password hashing and verification (`HashUtils`), AES-GCM secret + protection and a pluggable secret store (`ISecretProtector` / `ISecretStore`). +- **Configuration** — a YAML-backed config manager with section registration and environment-variable expansion. +- **Serialization** — unified JSON/YAML utilities (`JsonUtils`, `YamlUtils`) and a shared `IDataSerializer` / `IDataDeserializer`. +- **String & platform helpers** — case converters (camel/kebab/snake/pascal/…), network, version, platform and resource utilities. +- **Runtime services** — DI bootstrap, event bus, job system, timer/cron scheduler, metrics and storage. +- **Infrastructure modules** — messaging (in-memory + RabbitMQ), caching (in-memory + Redis), + database access, networking (TCP/UDP) and Lua scripting. + +Everything is modular: take only the packages you need, each behind a clean abstraction with an +in-memory implementation for tests and an external backend for production. ## Packages From a6fd733544707ae83b6f253cefee5e5ece34d135 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:11:31 +0200 Subject: [PATCH 93/98] docs(core,network): replace leftover Orion references with SquidStd equivalents - IEvent summary: 'Orion IRCd event bus' -> 'SquidStd event bus' - SquidStdUdpClient cref: OrionTcpClient -> SquidStdTcpClient (fixes broken XML doc xref) --- src/SquidStd.Core/Interfaces/Events/IEvent.cs | 2 +- src/SquidStd.Network/Client/SquidStdUdpClient.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SquidStd.Core/Interfaces/Events/IEvent.cs b/src/SquidStd.Core/Interfaces/Events/IEvent.cs index adf26c8b..3b4e0027 100644 --- a/src/SquidStd.Core/Interfaces/Events/IEvent.cs +++ b/src/SquidStd.Core/Interfaces/Events/IEvent.cs @@ -1,6 +1,6 @@ namespace SquidStd.Core.Interfaces.Events; /// -/// Marker contract for events dispatched through the Orion IRCd event bus. +/// Marker contract for events dispatched through the SquidStd event bus. /// public interface IEvent { } diff --git a/src/SquidStd.Network/Client/SquidStdUdpClient.cs b/src/SquidStd.Network/Client/SquidStdUdpClient.cs index e37d9a3d..bbd52973 100644 --- a/src/SquidStd.Network/Client/SquidStdUdpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdUdpClient.cs @@ -10,7 +10,7 @@ namespace SquidStd.Network.Client; /// Connectionless UDP client that binds a local socket and surfaces inbound datagrams through an /// async receive loop. Datagrams can be sent to any endpoint with , or to /// an optional default remote endpoint via . Mirrors the lifecycle surface of -/// (session id, connect/disconnect/data/exception events, and +/// (session id, connect/disconnect/data/exception events, and /// ). Supports Start once; recreate the instance to listen again. /// public sealed class SquidStdUdpClient : INetworkConnection, IAsyncDisposable, IDisposable From 6ddf606eb2f7986444defd094fcbe7060610556a Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:11:31 +0200 Subject: [PATCH 94/98] build(messaging-rabbitmq): bump RabbitMQ.Client to 7.2.1 --- .../SquidStd.Messaging.RabbitMq.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj index 400b64c3..f63ef6a3 100644 --- a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj +++ b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj @@ -12,7 +12,7 @@ - + From e47ea3b7a5f95dd41bcd32e7d4a8e3631bb0d74e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:15:19 +0200 Subject: [PATCH 95/98] docs: expand root README - add .NET 10 badge alongside CI/test/coverage - add Requirements and Quick Start (SquidStdBootstrap usage) - add Architecture (design principles) and Documentation (DocFX site) sections - add Contributing and Versioning & Releases sections - note Docker requirement for integration tests --- README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/README.md b/README.md index 3beda388..632b06a7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@

squid-std

+ .NET 10 CI tests coverage @@ -29,6 +30,39 @@ it bundles the foundations you reach for again and again behind small, well-defi Everything is modular: take only the packages you need, each behind a clean abstraction with an in-memory implementation for tests and an external backend for production. +## Requirements + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Docker](https://www.docker.com/) — only for running the integration tests (Testcontainers spin up RabbitMQ and Redis). + +## Quick Start + +```bash +dotnet add package SquidStd.Services.Core +``` + +```csharp +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; + +// Create the bootstrapper (registers the core services), then opt into the modules you need. +var bootstrap = SquidStdBootstrap.Create() + .ConfigureServices(container => container.AddInMemoryCache()); + +await bootstrap.StartAsync(); + +var cache = bootstrap.Resolve(); +await cache.SetAsync("answer", 42, TimeSpan.FromMinutes(5)); +var answer = await cache.GetAsync("answer"); + +await bootstrap.StopAsync(); +``` + +`SquidStdBootstrap` owns a DryIoc container, wires the core services, and drives the +`StartAsync` / `StopAsync` lifecycle of every registered `ISquidStdService`. Use `RunAsync` to +block until cancellation for long-running hosts. + ## Packages | Package | Description | Links | @@ -49,6 +83,27 @@ in-memory implementation for tests and an external backend for production. | `SquidStd.Caching.Redis` | Redis cache backend (`AddRedisCache`). | [readme](src/SquidStd.Caching.Redis/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.Redis.svg)](https://www.nuget.org/packages/SquidStd.Caching.Redis/) | | `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [readme](src/SquidStd.Scripting.Lua/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Scripting.Lua.svg)](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) | +## Architecture + +squid-std follows a few consistent principles across every module: + +- **KISS** — small, focused types; one class / record / enum per file. +- **Abstractions first** — each capability is split into an `*.Abstractions` package (interfaces, + DTOs, shared facade) plus one or more provider packages. Consumers depend on the abstraction. +- **In-memory + external provider** — every infrastructure module ships an in-memory provider + (great for tests and local dev) and a production backend behind the same interface + (messaging: in-memory / RabbitMQ; caching: in-memory / Redis). +- **DI-driven** — services are registered with DryIoc through `AddXxx(...)` extensions and resolved + through `SquidStdBootstrap`, which manages the `ISquidStdService` lifecycle. +- **Convention over restructure** — interfaces under `Interfaces`, DTOs under `Data`, enums under + `Types`, internals under `Internal`. See [`CODE_CONVENTION.md`](CODE_CONVENTION.md). + +## Documentation + +Full API documentation is published with DocFX to GitHub Pages: +**[tgiachi.github.io/SquidStd](https://tgiachi.github.io/SquidStd/)**. Each package also ships its +own README (linked in the table above). + ## Build ```bash @@ -61,6 +116,23 @@ dotnet build SquidStd.slnx dotnet test SquidStd.slnx ``` +Integration tests (RabbitMQ, Redis) need Docker running; they use Testcontainers to start +disposable containers automatically. + +## Contributing + +- Work happens on `develop`; `main` holds released code. +- Use [Conventional Commits](https://www.conventionalcommits.org/) for messages + (`feat:`, `fix:`, `refactor:`, `docs:`, `build:`, `test:`). +- Follow the project conventions in [`CODE_CONVENTION.md`](CODE_CONVENTION.md). +- Add tests for new behaviour and keep the suite green before opening a PR. + +## Versioning & Releases + +Releases are automated with [semantic-release](https://semantic-release.gitbook.io/): version +numbers and the changelog are derived from the conventional-commit history, and the NuGet packages +are published on release. Package versions follow [Semantic Versioning](https://semver.org/). + ## License MIT - see [LICENSE](LICENSE). From 6159e6c410fe98d121b708282a5bd1062bcf9c7a Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:16:27 +0200 Subject: [PATCH 96/98] docs: fix XML doc comment warnings (unqualified StartAsync crefs, duplicate param) --- src/SquidStd.Core/Utils/ResourceUtils.cs | 14 +++++++------- src/SquidStd.Network/Server/SquidStdUdpServer.cs | 2 +- src/SquidStd.Network/Server/SquidTcpServer.cs | 2 +- .../Interfaces/Scripts/IScriptEngineService.cs | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/SquidStd.Core/Utils/ResourceUtils.cs b/src/SquidStd.Core/Utils/ResourceUtils.cs index e1616400..2559ba47 100644 --- a/src/SquidStd.Core/Utils/ResourceUtils.cs +++ b/src/SquidStd.Core/Utils/ResourceUtils.cs @@ -288,13 +288,6 @@ public static string[] GetEmbeddedResourceNames(Assembly assembly = null, string return resourceNames.Where(name => name.Contains(normalizedPath)).ToArray(); } - ///

- /// Gets a stream for an embedded resource - /// - /// The assembly containing the resource - /// The full resource name - /// A stream for the resource - /// Thrown when the resource cannot be found /// /// Gets a stream for an embedded resource, inferring the assembly from . /// @@ -305,6 +298,13 @@ public static string[] GetEmbeddedResourceNames(Assembly assembly = null, string public static Stream GetEmbeddedResourceStream(string resourceName) => GetEmbeddedResourceStream(typeof(TClass).Assembly, resourceName); + /// + /// Gets a stream for an embedded resource. + /// + /// The assembly containing the resource. + /// The full resource name. + /// A stream for the resource. + /// Thrown when the resource cannot be found. public static Stream GetEmbeddedResourceStream(Assembly assembly, string resourceName) { ArgumentNullException.ThrowIfNull(assembly); diff --git a/src/SquidStd.Network/Server/SquidStdUdpServer.cs b/src/SquidStd.Network/Server/SquidStdUdpServer.cs index cee1cc3d..f3868104 100644 --- a/src/SquidStd.Network/Server/SquidStdUdpServer.cs +++ b/src/SquidStd.Network/Server/SquidStdUdpServer.cs @@ -92,7 +92,7 @@ public int ListenerCount public event EventHandler? OnException; /// - /// Initializes a UDP server bound to the given endpoint on every . + /// Initializes a UDP server bound to the given endpoint on every StartAsync. /// /// Endpoint supplying the port (and address when not binding all interfaces). /// diff --git a/src/SquidStd.Network/Server/SquidTcpServer.cs b/src/SquidStd.Network/Server/SquidTcpServer.cs index c4f71715..5456abcc 100644 --- a/src/SquidStd.Network/Server/SquidTcpServer.cs +++ b/src/SquidStd.Network/Server/SquidTcpServer.cs @@ -74,7 +74,7 @@ public sealed class SquidTcpServer : INetworkServer, IAsyncDisposable, IDisposab /// /// Initializes a TCP server bound to the given endpoint. /// - /// Endpoint to bind on every . + /// Endpoint to bind on every StartAsync. /// /// Optional framer template. The same instance is shared by all accepted clients, /// so implementations must be stateless or thread-safe. diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs index 6e3e3911..4380ce33 100644 --- a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs @@ -26,7 +26,7 @@ public interface IScriptEngineService : ISquidStdService event EventHandler? OnScriptError; /// - /// Fires once during , after script modules have been registered + /// Fires once during StartAsync, after script modules have been registered /// but before bootstrap scripts run. Handlers can install additional UserData types, globals, /// or scanners that depend on the script runtime being ready. The argument is the underlying /// MoonSharp Script, typed as so the interface stays From 8cd514a40f1b851c30d25dfbdb84e9099e644f74 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:16:27 +0200 Subject: [PATCH 97/98] docs(site): add Caching package articles to the DocFX site --- docs/articles/caching-abstractions.md | 1 + docs/articles/caching-redis.md | 1 + docs/articles/caching.md | 1 + docs/articles/toc.yml | 6 ++++++ 4 files changed, 9 insertions(+) create mode 100644 docs/articles/caching-abstractions.md create mode 100644 docs/articles/caching-redis.md create mode 100644 docs/articles/caching.md diff --git a/docs/articles/caching-abstractions.md b/docs/articles/caching-abstractions.md new file mode 100644 index 00000000..722af412 --- /dev/null +++ b/docs/articles/caching-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Caching.Abstractions/README.md)] diff --git a/docs/articles/caching-redis.md b/docs/articles/caching-redis.md new file mode 100644 index 00000000..f013c25c --- /dev/null +++ b/docs/articles/caching-redis.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Caching.Redis/README.md)] diff --git a/docs/articles/caching.md b/docs/articles/caching.md new file mode 100644 index 00000000..f98cf138 --- /dev/null +++ b/docs/articles/caching.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Caching/README.md)] diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index e278526b..730dd3da 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -22,5 +22,11 @@ href: messaging.md - name: SquidStd.Messaging.RabbitMq href: messaging-rabbitmq.md +- name: SquidStd.Caching.Abstractions + href: caching-abstractions.md +- name: SquidStd.Caching + href: caching.md +- name: SquidStd.Caching.Redis + href: caching-redis.md - name: SquidStd.Scripting.Lua href: scripting-lua.md From 9ed263b2840395f6e3c1b0109950524a9d29fc52 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 17:18:31 +0200 Subject: [PATCH 98/98] docs: add SquidStd.Caching to Quick Start install (snippet verified to compile and run) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 632b06a7..a373b75d 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ in-memory implementation for tests and an external backend for production. ```bash dotnet add package SquidStd.Services.Core +dotnet add package SquidStd.Caching ``` ```csharp