diff --git a/docs/articles/concepts/bootstrap-lifecycle.md b/docs/articles/concepts/bootstrap-lifecycle.md index bc5c3f09..b76278e2 100644 --- a/docs/articles/concepts/bootstrap-lifecycle.md +++ b/docs/articles/concepts/bootstrap-lifecycle.md @@ -14,7 +14,15 @@ var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions }); ``` -`ConfigName` selects the configuration file and `RootDirectory` anchors relative paths. Creating the bootstrap registers the configuration core - `DirectoriesConfig`, the `logger` config section and the config manager. Everything else is registered explicitly in `ConfigureServices`. +`ConfigName` selects the configuration file and `RootDirectory` anchors relative paths. `Create` +loads the configuration eagerly - the YAML file, or an empty document when it does not exist +yet - into a standalone `SquidStdConfig`, before any service registration happens. It also +registers the configuration core: `DirectoriesConfig`, the `logger` config section (bound +immediately against that `SquidStdConfig`) and the config manager. Everything else is +registered explicitly in `ConfigureServices`. To supply an already-loaded configuration +yourself - useful when values from the file must drive registration decisions - use the +`Create(SquidStdConfig, SquidStdOptions)` overload; see +[two-phase setup](../guides/configuration.md#two-phase-setup-moongate-style). ## ConfigureServices @@ -29,13 +37,18 @@ bootstrap.ConfigureServices(container => }); ``` +Config bound at registration: every `RegisterConfigSection` call inside this callback - direct, +or through a `RegisterXxx`/`AddXxx` helper - binds its section immediately, so the section +instance is resolvable as soon as the callback returns. There is no need to wait for +`StartAsync`. + See [dependency injection](dependency-injection.md) for the container and the `AddXxx` / `RegisterXxx` pattern. ## OnConfigLoaded -After the configuration is loaded - and before the logger is built and services start - the -bootstrap applies any typed config hooks. Use them to inspect or override loaded sections at -startup; changes are in-memory only: +Once, at `StartAsync` - before the logger is built and services start - the bootstrap applies +any typed config hooks you registered. Use them to inspect or override the already-bound +sections at startup; changes are in-memory only: ```csharp var bootstrap = SquidStdBootstrap.Create(o => o.ConfigName = "myapp"); @@ -45,10 +58,11 @@ bootstrap.OnConfigLoaded(o => o.MinimumLevel = LogLevelTy await bootstrap.StartAsync(); ``` -The effective order is: load configuration, apply config hooks, configure the logger, start -services. Hooks are re-applied on every configuration load, so overrides are never lost. To -receive the whole configuration manager once every typed hook has run, use -`bootstrap.OnConfigReady(cfg => ...)`. See +The effective order is: sections bind at registration (during `ConfigureServices`), config +hooks apply once at `StartAsync` entry, the logger is configured, then services start. Hooks +are re-applied whenever you call `IConfigManagerService.Load()` for an explicit reload, so +overrides are never lost across a reload. To receive the whole configuration manager once +every typed hook has run, use `bootstrap.OnConfigReady(cfg => ...)`. See [Inspecting and overriding loaded configuration](../guides/configuration.md#inspecting-and-overriding-loaded-configuration) for more examples. ## Migrating to 0.15: explicit core services @@ -73,7 +87,7 @@ builder.UseSquidStd(options => options.ConfigName = "myapp", c => c.RegisterCore ## Start and stop over ISquidStdService -Services implementing `ISquidStdService` participate in the lifecycle. On `StartAsync` the configuration is loaded and the config hooks are applied, then services are started in registration order; on `StopAsync` they are stopped in reverse order, so dependencies remain available while their dependents shut down. +Services implementing `ISquidStdService` participate in the lifecycle. On `StartAsync` the config hooks are applied once (mutating the already-bound sections in place) and, if the configuration file does not exist yet, it is written with defaults; then services are started in registration order. On `StopAsync` they are stopped in reverse order, so dependencies remain available while their dependents shut down. The bootstrap logs its whole lifecycle: a startup banner with the application name and version (set `SquidStdOptions.AppName`; it defaults to the entry assembly name and is attached to every event as the `Application` / `ApplicationVersion` properties), a registration summary (per-registration detail at Debug), one line per service started with its duration, and the shutdown sequence. A service that fails to stop is logged as a warning and the remaining services are still stopped. Extra Serilog sinks can be plugged by registering `ILogEventSink` instances in the container before start. diff --git a/docs/articles/guides/configuration.md b/docs/articles/guides/configuration.md index f0f064c6..b2dfc8c7 100644 --- a/docs/articles/guides/configuration.md +++ b/docs/articles/guides/configuration.md @@ -1,9 +1,25 @@ # Configuration -SquidStd loads configuration from a single YAML file. Each module registers a -strongly-typed section; the config manager deserializes it, expands environment -variables, and publishes the populated object into the container so you can -resolve it like any other service. +SquidStd loads configuration from a single YAML file, eagerly, before any service is +registered. Each module binds a strongly-typed section at registration time: the value is +already in the container as soon as the `Register*` call returns, expanded and ready to +resolve like any other service. + +## The config-first flow + +1. **Create loads eagerly.** `SquidStdBootstrap.Create(options)` reads the YAML file into a + standalone `SquidStdConfig` before any container registration happens (a missing file + yields an empty document; nothing is written yet). +2. **Registrations bind immediately.** Every `RegisterConfigSection` call - direct, + or through a `RegisterXxx` helper - binds its section against that `SquidStdConfig` right + away and registers the resulting instance as a singleton. There is no "config not ready + yet" window: resolve the type straight after the `Register*` call. +3. **Hooks run once, at `StartAsync`.** Typed `OnConfigLoaded` hooks and `OnConfigReady` + callbacks are applied a single time, right before the logger is configured and services + start. They mutate the already-bound instances in place. +4. **Explicit `Load()` is a reload.** Call `IConfigManagerService.Load()` to re-read the file + from disk, re-bind every tracked section, and re-apply the hooks. This is the only way + configuration changes after startup - there is no background file watch. ## Steps @@ -12,28 +28,42 @@ resolve it like any other service. `RootDirectory` is the directory it is searched in. 2. **Register a section.** Call `RegisterConfigSection` with the section name (the top-level YAML key). Provide a `createDefault` factory so the file - is generated with sensible defaults on first run. -3. **Use environment variables.** Any `string` property whose value contains a - `$VAR` token is expanded from the environment when the section is loaded. - Unknown tokens are left untouched. -4. **Read values.** Resolve the config type from the container wherever you need it. + is generated with sensible defaults on first run (the file is written at `StartAsync` + when it does not exist yet, once every section is known). +3. **Use environment variables in SquidStd sections.** Any `string` property of a type whose + namespace starts with `SquidStd` (the framework's own sections and any section type shipped + in a `SquidStd.*` package) is expanded from the environment when the section is bound: a + `$VAR` token is replaced with the matching environment variable, unknown tokens are left + untouched. Application-defined sections, like `MyServiceConfig` below, are not walked + automatically - call the same expansion yourself with the `string` extension `ReplaceEnv()` + (`SquidStd.Core.Extensions.Env`) wherever you read the value. +4. **Read values.** Resolve the config type from the container wherever you need it - the + value is available as soon as the registration call returns. ```csharp public sealed class MyServiceConfig { public string Endpoint { get; set; } = string.Empty; // e.g. "https://$API_HOST" public int Retries { get; set; } = 3; + + // MyServiceConfig lives outside the SquidStd namespace, so it is not walked by the + // automatic substitution pass. Expand tokens explicitly where you read the value: + // config.Endpoint = config.Endpoint.ReplaceEnv(); } var bootstrap = SquidStdBootstrap.Create( new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory }); bootstrap.ConfigureServices(container => - container.RegisterConfigSection("myService", static () => new MyServiceConfig())); +{ + container.RegisterConfigSection("myService", static () => new MyServiceConfig()); -await bootstrap.StartAsync(); + var config = container.Resolve(); // already bound, no need to wait for StartAsync -var config = container.Resolve(); + return container; +}); + +await bootstrap.StartAsync(); ``` The corresponding `squidstd.yaml`: @@ -44,9 +74,73 @@ myService: retries: 5 ``` +## Four ways to configure a service + +Sectioned registrations (`RegisterEventLoop`, `RegisterJobSystemService`, +`RegisterTimerWheelService`, `RegisterMetricsCollectionService`, `RegisterSecretServices`, and +the other `RegisterXxx`/`AddXxx` helpers) accept an optional explicit config instance, so there +are four ways to supply a value, from least to most code: + +1. **Standard section from file** - call the registration with no config; it binds the + named YAML section (or the `createDefault` factory when the section is absent): + + ```csharp + container.RegisterEventLoop(); // binds the "eventLoop" section + ``` + +2. **`OnConfigLoaded` hook** - keep the standard binding, but adjust the bound instance + once at startup: + + ```csharp + bootstrap.OnConfigLoaded(c => c.IdleSleepMs = 5); + ``` + +3. **Derived from another section** - bind a parent section yourself and pass one of its + nested values through: + + ```csharp + var appConfig = config.GetSection("myapp"); + container.RegisterEventLoop(appConfig.EventLoop); + ``` + +4. **Explicit instance** - bypass the file entirely: + + ```csharp + container.RegisterEventLoop(new EventLoopConfig { IdleSleepMs = 0 }); + ``` + +Options 3 and 4 register the instance directly (`IfAlreadyRegistered.Replace`) and skip +`RegisterConfigSection`, so the YAML file is never read for that section. + +## Two-phase setup (Moongate-style) + +Apps that need configuration before any service is registered - to size worker pools, pick a +storage backend, and so on - load the `SquidStdConfig` themselves and pass it to `Create`: + +```csharp +// Phase 1 - config loaded immediately, no container involved +var config = SquidStdConfig.Load("moongate", "~/.moongate"); + +// Phase 2 - registrations with real config objects +var bootstrap = SquidStdBootstrap.Create( + config, + new SquidStdOptions { ConfigName = "moongate", RootDirectory = "~/.moongate" } + ) + .ConfigureServices(c => + { + c.RegisterEventLoop(config.GetSection("eventLoop")); // from file, direct + c.RegisterTimerWheelService(config.GetSection("moongate").TimerWheel); // derived + c.RegisterJobSystemService(new JobsConfig { WorkerThreadCount = 8 }); // code only + c.RegisterMetricsCollectionService(); // standard section, binds at registration + return c; + }); + +await bootstrap.RunAsync(); +``` + ## Inspecting and overriding loaded configuration -The config manager loads sections transparently, but nothing stays hidden: +The config manager binds sections transparently, but nothing stays hidden: ```csharp var config = bootstrap.Resolve(); @@ -55,9 +149,9 @@ var logger = config.GetConfig(); // one typed section ``` To inspect or tweak a section at startup - before the logger and the services consume it - -register a typed hook on the bootstrap. Hooks run after every configuration load and mutate -the section in memory only: the YAML file is never rewritten (call `Save()` explicitly if you -want to persist). +register a typed hook on the bootstrap. Hooks run once, right after the configuration is +loaded, and mutate the section in memory only: the YAML file is never rewritten (call `Save()` +explicitly if you want to persist). ```csharp // Tune a section at startup @@ -91,5 +185,17 @@ bootstrap.OnConfigReady(cfg => }); ``` -`OnConfigReady` follows the same rules as the typed hooks: it runs after every configuration -load, before the logger and the services, and must be registered before the bootstrap starts. +`OnConfigReady` follows the same rules as the typed hooks: it runs once, after the +configuration is loaded and before the logger and the services, and must be registered +before the bootstrap starts. + +## Reloading configuration + +Both the typed hooks and `OnConfigReady` also run again on an explicit reload: +`bootstrap.Resolve().Load()` re-reads the YAML file, re-binds every +tracked section, and re-applies every hook - so overrides made with `OnConfigLoaded` are never +lost across a reload. There is no automatic file watch: call `Load()` yourself when you know +the file changed. + +Rebinding replaces the section's registration in the container - a service that already holds +a direct reference to the old instance keeps using it until it resolves the section again. diff --git a/docs/articles/scheduler.md b/docs/articles/scheduler.md index e3284ed1..45031994 100644 --- a/docs/articles/scheduler.md +++ b/docs/articles/scheduler.md @@ -64,6 +64,8 @@ eventLoop: SlowTickThresholdMs: 250 # warn above this per-tick time ``` +Skip the file entirely with an explicit instance: `RegisterEventLoop(new EventLoopConfig { IdleSleepMs = 0 })`. + ```mermaid flowchart TD subgraph Drivers["Drivers - register exactly one"] diff --git a/docs/tutorials/scripting-lua.md b/docs/tutorials/scripting-lua.md index b8434730..582a4adb 100644 --- a/docs/tutorials/scripting-lua.md +++ b/docs/tutorials/scripting-lua.md @@ -54,10 +54,11 @@ result = hello from C# and lua ## How it works -`IScriptEngineService` wraps a MoonSharp `Script`. Registering it with `RegisterStdService` lets the bootstrap -call its `StartAsync` during `StartAsync`, which wires up modules, constants, and a file watcher over the -configured scripts directory. Globals you register become Lua variables, and `ExecuteFunction` evaluates a -`return ` and surfaces the value through `ScriptResult.Data`. +`IScriptEngineService` wraps a MoonSharp `Script`. `RegisterLuaEngine` registers the `LuaEngineConfig` instance +and the engine as the standard script engine service in one call, so the bootstrap calls its `StartAsync` +during `StartAsync`, wiring up modules, constants, and a file watcher over the configured scripts directory. +Globals you register become Lua variables, and `ExecuteFunction` evaluates a `return ` and +surfaces the value through `ScriptResult.Data`. ## See also diff --git a/samples/SquidStd.Samples.ScriptingLua/Program.cs b/samples/SquidStd.Samples.ScriptingLua/Program.cs index 64910b74..289fe1dd 100644 --- a/samples/SquidStd.Samples.ScriptingLua/Program.cs +++ b/samples/SquidStd.Samples.ScriptingLua/Program.cs @@ -1,5 +1,4 @@ using DryIoc; -using SquidStd.Abstractions.Extensions.Services; using SquidStd.Generators.Scripting.Lua; using SquidStd.Scripting.Lua.Attributes; using SquidStd.Scripting.Lua.Attributes.Scripts; @@ -37,8 +36,7 @@ "1.0.0" ); - container.RegisterInstance(engineConfig); - container.RegisterStdService(); + container.RegisterLuaEngine(engineConfig); container.RegisterGeneratedScriptModules(); container.RegisterScriptModule(); container.RegisterLuaEvents(); diff --git a/src/SquidStd.Abstractions/Data/Internal/Config/ConfigRegistrationData.cs b/src/SquidStd.Abstractions/Data/Internal/Config/ConfigRegistrationData.cs deleted file mode 100644 index 2606a041..00000000 --- a/src/SquidStd.Abstractions/Data/Internal/Config/ConfigRegistrationData.cs +++ /dev/null @@ -1,43 +0,0 @@ -using SquidStd.Core.Interfaces.Config; - -namespace SquidStd.Abstractions.Data.Internal.Config; - -public sealed class ConfigRegistrationData : IConfigEntry -{ - private readonly Func _createDefault; - - public int Priority { get; } - - public string SectionName { get; } - - public Type ConfigType { get; } - - public ConfigRegistrationData( - string sectionName, - Type configType, - Func createDefault, - int priority = 0 - ) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); - ArgumentNullException.ThrowIfNull(configType); - ArgumentNullException.ThrowIfNull(createDefault); - - SectionName = sectionName; - ConfigType = configType; - _createDefault = createDefault; - Priority = priority; - } - - public object CreateDefault() - { - var config = _createDefault(); - - if (!ConfigType.IsInstanceOfType(config)) - { - throw new InvalidOperationException("Default config factory returned an incompatible object."); - } - - return config; - } -} diff --git a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs index 822c32d0..21d8efbc 100644 --- a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs +++ b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs @@ -1,6 +1,6 @@ using DryIoc; -using SquidStd.Abstractions.Data.Internal.Config; -using SquidStd.Abstractions.Extensions.Container; +using Serilog; +using SquidStd.Core.Config; namespace SquidStd.Abstractions.Extensions.Config; @@ -19,35 +19,50 @@ public static IContainer RegisterConfigSection( var configType = typeof(TConfig); - if (container.IsRegistered>()) + if (container.IsRegistered()) { - var entries = container.Resolve>(); - var sameSection = entries.FirstOrDefault( - entry => string.Equals( - entry.SectionName, - sectionName, - StringComparison.Ordinal - ) - ); - - if (sameSection is not null) + if (container.IsRegistered()) { - if (sameSection.ConfigType == configType) + var trackedUnderOtherSection = container.Resolve() + .Entries + .Any( + entry => entry.ConfigType == configType && + !string.Equals( + entry.SectionName, + sectionName, + StringComparison.Ordinal + ) + ); + + if (trackedUnderOtherSection) { - return container; + throw new InvalidOperationException($"Config type '{configType.Name}' is already registered."); } - - throw new InvalidOperationException($"Config section '{sectionName}' is already registered."); } - if (entries.Any(entry => entry.ConfigType == configType)) - { - throw new InvalidOperationException($"Config type '{configType.Name}' is already registered."); - } + Log.ForContext(typeof(RegisterConfigSectionExtension)) + .Debug( + "Config section {Section:l} skipped: an explicit {ConfigType:l} instance is already registered", + sectionName, + configType.Name + ); + + return container; + } + + if (container.IsRegistered()) + { + var squidConfig = container.Resolve(); + var bound = squidConfig.BindSection(sectionName, createDefault, priority); + container.RegisterInstance(bound, IfAlreadyRegistered.Replace); + + return container; } - var factory = createDefault ?? (() => new()); - container.AddToRegisterTypedList(new ConfigRegistrationData(sectionName, configType, () => factory(), priority)); + // Degenerate mode (bare container without the bootstrap/SquidStdConfig): register the + // default instance; there is no file to bind from. + var fallback = createDefault?.Invoke() ?? new TConfig(); + container.RegisterInstance(fallback, IfAlreadyRegistered.Keep); return container; } diff --git a/src/SquidStd.Abstractions/README.md b/src/SquidStd.Abstractions/README.md index 39468435..618ac792 100644 --- a/src/SquidStd.Abstractions/README.md +++ b/src/SquidStd.Abstractions/README.md @@ -35,7 +35,6 @@ container.RegisterConfigSection("my"); | `RegisterStdServiceExtension` | `RegisterStdService<,>` container extension. | | `RegisterConfigSectionExtension` | `RegisterConfigSection<>` container extension. | | `ServiceRegistrationData` | Ordered service registration record. | -| `ConfigRegistrationData` | Config section registration record. | ## Related diff --git a/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs index d759c057..67a18cd6 100644 --- a/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs +++ b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs @@ -26,10 +26,18 @@ public static class ConsoleCommandsRegistrationExtensions /// the prompt. Set logger.EnableConsole: false so the sink replaces the standard /// console sink. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer AddConsoleCommands() + public IContainer AddConsoleCommands(ConsoleCommandsConfig? config = null) { - container.RegisterConfigSection("consoleCommands", static () => new ConsoleCommandsConfig(), -50); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("consoleCommands", static () => new ConsoleCommandsConfig(), -50); + } container.Register(Reuse.Singleton); container.RegisterDelegate( resolver => diff --git a/src/SquidStd.Core/Config/Internal/ConfigEnvSubstitution.cs b/src/SquidStd.Core/Config/Internal/ConfigEnvSubstitution.cs new file mode 100644 index 00000000..d629f1e9 --- /dev/null +++ b/src/SquidStd.Core/Config/Internal/ConfigEnvSubstitution.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using SquidStd.Core.Extensions.Env; + +namespace SquidStd.Core.Config.Internal; + +/// +/// Recursively substitutes environment-variable tokens into string properties of a bound +/// configuration instance and its nested SquidStd-namespaced class properties. +/// +internal static class ConfigEnvSubstitution +{ + /// + /// Applies environment-variable substitution to and its nested + /// properties. + /// + /// The configuration instance to process. + public static void Apply(object? instance) + => Apply(instance, new(ReferenceEqualityComparer.Instance)); + + private static void Apply(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) + { + Apply(property.GetValue(instance), visited); + } + } + } +} diff --git a/src/SquidStd.Core/Config/Internal/ConfigSectionEntry.cs b/src/SquidStd.Core/Config/Internal/ConfigSectionEntry.cs new file mode 100644 index 00000000..f5c90800 --- /dev/null +++ b/src/SquidStd.Core/Config/Internal/ConfigSectionEntry.cs @@ -0,0 +1,48 @@ +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Core.Config.Internal; + +/// +/// Tracks a single configuration section registered with a instance, +/// including its bind priority and cached bound instance. +/// +internal sealed class ConfigSectionEntry : IConfigEntry +{ + private readonly Func _createDefault; + + /// + public string SectionName { get; } + + /// + public Type ConfigType { get; } + + /// + /// Gets the ordering priority used by and + /// . + /// + public int Priority { get; } + + /// + /// Gets or sets the cached bound instance, or when not yet bound. + /// + public object? Instance { get; set; } + + /// + /// Initializes a new section entry. + /// + /// The top-level YAML section name. + /// The section CLR type. + /// Ordering priority for Compose/Save. + /// Factory producing the default instance. + public ConfigSectionEntry(string sectionName, Type configType, int priority, Func createDefault) + { + SectionName = sectionName; + ConfigType = configType; + Priority = priority; + _createDefault = createDefault; + } + + /// + public object CreateDefault() + => _createDefault(); +} diff --git a/src/SquidStd.Core/Config/SquidStdConfig.cs b/src/SquidStd.Core/Config/SquidStdConfig.cs new file mode 100644 index 00000000..28ac1e28 --- /dev/null +++ b/src/SquidStd.Core/Config/SquidStdConfig.cs @@ -0,0 +1,257 @@ +using SquidStd.Core.Config.Internal; +using SquidStd.Core.Extensions.Directories; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Yaml; + +namespace SquidStd.Core.Config; + +/// +/// Standalone YAML configuration loader. Loads the raw document eagerly and binds sections on +/// demand, outside any dependency-injection container. Instances are cached per section and +/// environment-variable substitution is applied at bind time. +/// +public sealed class SquidStdConfig +{ + private readonly Lock _sync = new(); + private readonly Dictionary _sections = new(StringComparer.Ordinal); + private string _rawYaml; + + /// Gets the logical configuration name. + public string ConfigName { get; } + + /// Gets the fully-resolved configuration directory. + public string ConfigDirectory { get; } + + /// Gets the full path of the YAML configuration file. + public string ConfigPath { get; } + + /// Gets the tracked sections (bound and unbound), ordered by priority then name. + public IReadOnlyCollection Entries + { + get + { + lock (_sync) + { + return OrderedEntries().Cast().ToArray(); + } + } + } + + private SquidStdConfig(string configName, string configDirectory, string rawYaml) + { + ConfigName = configName; + ConfigDirectory = configDirectory; + ConfigPath = ResolveConfigPath(configName, configDirectory); + _rawYaml = rawYaml; + } + + /// + /// Loads the configuration file eagerly. A missing file yields an empty document (defaults); + /// the file is not created. Tilde and environment variables in + /// are resolved. + /// + /// Logical configuration name or YAML file name. + /// Directory where the configuration file is searched. + /// The loaded configuration. + public static SquidStdConfig Load(string configName, string configDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configName); + ArgumentException.ThrowIfNullOrWhiteSpace(configDirectory); + + var resolvedDirectory = Path.GetFullPath(configDirectory.ResolvePathAndEnvs()); + var path = ResolveConfigPath(configName, resolvedDirectory); + var raw = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + + return new(configName, resolvedDirectory, raw); + } + + /// + /// Binds the named section (YAML content or defaults) and caches it: repeated calls return + /// the same instance. Environment variables in string properties are substituted. + /// + /// The section type. + /// The YAML section name. + /// The bound section instance. + public TConfig GetSection(string sectionName) + where TConfig : class, new() + => BindSection(sectionName, null, 0); + + /// + /// Binds the named section with an explicit default factory and Compose ordering priority. + /// Used by the registration extensions; behaves like . + /// + /// The section type. + /// The YAML section name. + /// Factory used when the section is absent from the file. + /// Ordering priority for Compose/Save. + /// The bound section instance. + public TConfig BindSection(string sectionName, Func? createDefault, int priority) + where TConfig : class, new() + { + ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); + + lock (_sync) + { + var entry = EnsureEntry( + sectionName, + typeof(TConfig), + priority, + createDefault is null ? static () => new TConfig() : () => createDefault() + ); + + entry.Instance ??= Bind(entry); + + return (TConfig)entry.Instance; + } + } + + /// + /// Records a section for Compose/Save without binding it. Infrastructure API used by the + /// registration extensions. + /// + /// The YAML section name. + /// The section CLR type. + /// Ordering priority for Compose/Save. + /// Factory producing the default instance. + public void TrackSection(string sectionName, Type configType, int priority, Func createDefault) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); + ArgumentNullException.ThrowIfNull(configType); + ArgumentNullException.ThrowIfNull(createDefault); + + lock (_sync) + { + EnsureEntry(sectionName, configType, priority, createDefault); + } + } + + /// + /// True when the raw document contains the named top-level section. + /// + /// The YAML section name. + public bool HasSection(string sectionName) + { + lock (_sync) + { + return !string.IsNullOrWhiteSpace(_rawYaml) && + YamlUtils.DeserializeSection(_rawYaml, sectionName, typeof(Dictionary)) is not null; + } + } + + /// + /// Re-reads the configuration file and clears the bind cache; tracked sections are kept and + /// rebind lazily (or via ). + /// + public void Reload() + { + lock (_sync) + { + _rawYaml = File.Exists(ConfigPath) ? File.ReadAllText(ConfigPath) : string.Empty; + + foreach (var entry in _sections.Values) + { + entry.Instance = null; + } + } + } + + /// + /// Binds every tracked section and returns the bound instances (priority order). + /// + public IReadOnlyList<(string SectionName, Type ConfigType, object Instance)> BindAll() + { + lock (_sync) + { + var result = new List<(string, Type, object)>(_sections.Count); + + foreach (var entry in OrderedEntries()) + { + entry.Instance ??= Bind(entry); + result.Add((entry.SectionName, entry.ConfigType, entry.Instance)); + } + + return result; + } + } + + /// + /// Serializes every tracked section (bound instances, or defaults for unbound ones). + /// + public string Compose() + { + lock (_sync) + { + return YamlUtils.SerializeSections(BuildSectionMap()); + } + } + + /// + /// Writes the composed configuration to . + /// + public void Save() + { + lock (_sync) + { + YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath); + } + } + + private ConfigSectionEntry EnsureEntry(string sectionName, Type configType, int priority, Func createDefault) + { + if (_sections.TryGetValue(sectionName, out var existing)) + { + if (existing.ConfigType != configType) + { + throw new InvalidOperationException($"Config section '{sectionName}' is already registered."); + } + + return existing; + } + + if (_sections.Values.Any(entry => entry.ConfigType == configType)) + { + throw new InvalidOperationException($"Config type '{configType.Name}' is already registered."); + } + + var created = new ConfigSectionEntry(sectionName, configType, priority, createDefault); + _sections[sectionName] = created; + + return created; + } + + private object Bind(ConfigSectionEntry entry) + { + var value = string.IsNullOrWhiteSpace(_rawYaml) + ? entry.CreateDefault() + : YamlUtils.DeserializeSection(_rawYaml, entry.SectionName, entry.ConfigType) ?? + entry.CreateDefault(); + + ConfigEnvSubstitution.Apply(value); + + return value; + } + + private Dictionary BuildSectionMap() + { + var sections = new Dictionary(StringComparer.Ordinal); + + foreach (var entry in OrderedEntries()) + { + sections[entry.SectionName] = entry.Instance ?? entry.CreateDefault(); + } + + return sections; + } + + private IEnumerable OrderedEntries() + => _sections.Values + .OrderBy(entry => entry.Priority) + .ThenBy(entry => entry.SectionName, StringComparer.Ordinal); + + private static string ResolveConfigPath(string configName, string configDirectory) + { + var fileName = Path.HasExtension(configName) ? configName : $"{configName}.yaml"; + + return Path.Combine(configDirectory, fileName); + } +} diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs index 5024eead..a1538188 100644 --- a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs @@ -27,4 +27,10 @@ public sealed class SquidStdOptions /// version is used. /// public string? AppVersion { get; set; } + + /// + /// Explicit logger configuration. When set, the "logger" YAML section is not bound and + /// Serilog is configured from this instance only; the file cannot override it. + /// + public SquidStdLoggerOptions? Logger { get; set; } } diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index 1e6639f2..5d7d20bf 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -69,7 +69,7 @@ public interface ISquidStdBootstrap : IAsyncDisposable TService Resolve(); /// - /// Loads configuration and configures the Serilog logger immediately, if not already configured. + /// Applies the registered configuration hooks and configures the Serilog logger immediately, if not already configured. /// Idempotent: subsequent calls are no-ops. Lets ASP.NET hosts wire Serilog before the host starts. /// void ConfigureLogging(); diff --git a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs index cefb6ed6..57ff1980 100644 --- a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs +++ b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs @@ -45,7 +45,8 @@ public interface IConfigManagerService TConfig GetConfig() where TConfig : class; /// - /// Loads or creates the configured YAML file and registers every section into DI. + /// Re-reads the YAML file, re-binds every tracked section into the container and raises . + /// A missing file is not created. /// void Load(); diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEngineExtension.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEngineExtension.cs new file mode 100644 index 00000000..85933c0c --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEngineExtension.cs @@ -0,0 +1,33 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Scripting.Lua.Data.Config; +using SquidStd.Scripting.Lua.Interfaces.Scripts; +using SquidStd.Scripting.Lua.Services; + +namespace SquidStd.Scripting.Lua.Extensions.Scripts; + +/// +/// Registration helpers for the Lua script engine. +/// +public static class RegisterLuaEngineExtension +{ + /// Container that receives the registrations. + extension(IContainer container) + { + /// + /// Registers the Lua script engine with its explicit configuration: the config instance + /// and the as the standard script engine service. + /// Replaces the manual RegisterInstance + RegisterStdService pair. + /// + /// The Lua engine configuration. + /// The same container for chaining. + public IContainer RegisterLuaEngine(LuaEngineConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + + return container.RegisterStdService(); + } + } +} diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 15e553c3..a9792f7d 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.Config; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Events; using SquidStd.Core.Data.Jobs; @@ -35,14 +36,19 @@ public static class RegisterDefaultServicesExtensions extension(IContainer container) { /// - /// Registers the default config manager service as a singleton instance. + /// Registers the default config manager service as a singleton instance, loading a + /// standalone for the given name/directory. Any subsequent + /// RegisterConfigSection call on this container binds eagerly against it. /// /// 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); + var config = SquidStdConfig.Load(configName, configDirectory); + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + + var service = new ConfigManagerService(config, container); container.RegisterInstance(service, IfAlreadyRegistered.Replace); container.RegisterInstance(service, IfAlreadyRegistered.Replace); container.AddToRegisterTypedList( @@ -55,16 +61,38 @@ public IContainer RegisterConfigManagerService(string configName, string configD /// /// Registers only the configuration core: the shared , the /// logger config section and the config manager. Every other service is opt-in via - /// RegisterCoreServices() or the individual registration methods. + /// RegisterCoreServices() or the individual registration methods. Loads a standalone + /// for the given name/directory. /// + /// + /// Registers a fresh SquidStdConfig: do not call this on a container that already has one (the previous instance and its tracked sections would be replaced). + /// /// The logical config name or YAML file name. /// The directory where the config file is searched. /// The same container for chaining. public IContainer RegisterConfigServices(string configName, string configDirectory) + => container.RegisterConfigServices(SquidStdConfig.Load(configName, configDirectory)); + + /// + /// Registers only the configuration core against an already-loaded : + /// the config instance itself, the shared , the logger config + /// section and the config manager. Every other service is opt-in via RegisterCoreServices() + /// or the individual registration methods. Once the config instance is registered, every + /// RegisterConfigSection call on this container binds eagerly against it. + /// + /// The eagerly-loaded configuration. + /// The same container for chaining. + public IContainer RegisterConfigServices(SquidStdConfig config) { - container.RegisterInstance(new DirectoriesConfig(configDirectory, []), IfAlreadyRegistered.Keep); + ArgumentNullException.ThrowIfNull(config); + + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + container.RegisterInstance(new DirectoriesConfig(config.ConfigDirectory, []), IfAlreadyRegistered.Keep); container.RegisterConfigSection("logger", static () => new SquidStdLoggerOptions(), -1000); - container.RegisterConfigManagerService(configName, configDirectory); + container.RegisterDelegate( + resolver => new ConfigManagerService(resolver.Resolve(), (IContainer)resolver), + Reuse.Singleton + ); return container; } @@ -156,10 +184,18 @@ public IContainer RegisterFileWatcherService(TimeSpan? debounceDelay = null) /// /// Registers the default job system service in the container. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer RegisterJobSystemService() + public IContainer RegisterJobSystemService(JobsConfig? config = null) { - container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); + } return container.RegisterStdService(-1); } @@ -174,10 +210,18 @@ public IContainer RegisterMainThreadDispatcherService() /// /// Registers the default metrics collection service in the container. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer RegisterMetricsCollectionService() + public IContainer RegisterMetricsCollectionService(MetricsConfig? config = null) { - container.RegisterConfigSection("metrics", static () => new MetricsConfig(), -80); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("metrics", static () => new MetricsConfig(), -80); + } return container.RegisterStdService(1000); } @@ -185,10 +229,19 @@ public IContainer RegisterMetricsCollectionService() /// /// Registers default encrypted local secret services in the container. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer RegisterSecretServices() + public IContainer RegisterSecretServices(SecretsConfig? config = null) { - container.RegisterConfigSection("secrets", static () => new SecretsConfig(), -60); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("secrets", static () => new SecretsConfig(), -60); + } + container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); @@ -198,10 +251,18 @@ public IContainer RegisterSecretServices() /// /// Registers the default timer wheel service in the container. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer RegisterTimerWheelService() + public IContainer RegisterTimerWheelService(TimerWheelConfig? config = null) { - container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + } return container.RegisterStdService(-1); } diff --git a/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs b/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs index b04fd4f9..a9b2112d 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs @@ -18,10 +18,19 @@ public static class RegisterHealthChecksServiceExtension /// Registers the health-check aggregator () as a singleton. /// Concrete checks register themselves as IHealthCheck and are collected automatically. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer RegisterHealthChecksService() + public IContainer RegisterHealthChecksService(HealthCheckOptions? config = null) { - container.RegisterConfigSection("healthChecks", static () => new HealthCheckOptions(), -80); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("healthChecks", static () => new HealthCheckOptions(), -80); + } + container.Register(Reuse.Singleton); return container; diff --git a/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs index 7ce23af1..48e6ef45 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs @@ -24,10 +24,19 @@ public static class RegisterSchedulerServicesExtension /// Registers the timer wheel pump and the cron scheduler. Must be called after /// RegisterCoreServices so that ITimerService and IJobSystem exist. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer RegisterSchedulerServices() + public IContainer RegisterSchedulerServices(TimerWheelPumpConfig? pumpConfig = null) { - container.RegisterConfigSection("timerWheelPump", static () => new TimerWheelPumpConfig(), -90); + if (pumpConfig is not null) + { + container.RegisterInstance(pumpConfig, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("timerWheelPump", static () => new TimerWheelPumpConfig(), -90); + } + container.RegisterStdService(-1); container.RegisterMapping(); container.RegisterStdService(-1); @@ -41,8 +50,9 @@ public IContainer RegisterSchedulerServices() /// already registered. Must be called after RegisterCoreServices so that /// IMainThreadDispatcher and ITimerService exist. /// + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. /// The same container for chaining. - public IContainer RegisterEventLoop() + public IContainer RegisterEventLoop(EventLoopConfig? config = null) { if (container.IsRegistered()) { @@ -51,7 +61,15 @@ public IContainer RegisterEventLoop() ); } - container.RegisterConfigSection("eventLoop", static () => new EventLoopConfig(), -90); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("eventLoop", static () => new EventLoopConfig(), -90); + } + container.RegisterStdService(-1); container.RegisterMapping(); container.RegisterMapping(); diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index e082a909..57a5e529 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -4,9 +4,9 @@ using Serilog; using Serilog.Core; using Serilog.Events; -using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Data.Internal.Services; using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Config; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Extensions.Logger; using SquidStd.Core.Interfaces.Bootstrap; @@ -59,7 +59,7 @@ public SquidStdBootstrap() public SquidStdBootstrap(SquidStdOptions options) : this(options, new Container(), true) { } - private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ownsContainer) + private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ownsContainer, SquidStdConfig? config = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(container); @@ -74,7 +74,15 @@ private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ow Container.RegisterInstance(this, IfAlreadyRegistered.Replace); Container.RegisterInstance(Options, IfAlreadyRegistered.Replace); Container.RegisterInstance(_lifetime, IfAlreadyRegistered.Replace); - Container.RegisterConfigServices(Options.ConfigName, Options.RootDirectory); + + // Registered before RegisterConfigServices so its guard skips binding the "logger" section + // from the file: an explicit instance always wins over the file. + if (Options.Logger is not null) + { + Container.RegisterInstance(Options.Logger, IfAlreadyRegistered.Replace); + } + + Container.RegisterConfigServices(config ?? SquidStdConfig.Load(Options.ConfigName, Options.RootDirectory)); } /// @@ -194,7 +202,7 @@ public void ConfigureLogging() _configHooksSubscribed = true; } - configManager.Load(); + ApplyConfigHooks(); ConfigureLogger(); } @@ -230,7 +238,12 @@ public async Task RunAsync(CancellationToken cancellationToken = default) } } - /// + /// + /// Starts the bootstrap and all registered SquidStd services. + /// + /// + /// When the configuration file does not exist it is written after the configuration hooks have run, so the file reflects the effective startup configuration. + /// public async ValueTask StartAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -241,6 +254,13 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) { ConfigureLogging(); + var squidConfig = Container.Resolve(); + + if (!File.Exists(squidConfig.ConfigPath)) + { + Container.Resolve().Save(); + } + var logger = Log.ForContext(); var appName = ResolveAppName(Options); @@ -407,6 +427,35 @@ public static SquidStdBootstrap Create(Action configure) return Create(options); } + /// + /// Creates a bootstrapper from an eagerly-loaded , bypassing the + /// internal the other overloads perform. + /// + /// The eagerly-loaded configuration. + /// Bootstrap options used to register core services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdConfig config, SquidStdOptions options) + { + ArgumentNullException.ThrowIfNull(config); + + return new SquidStdBootstrap(options, new Container(), true, config); + } + + /// + /// Creates a bootstrapper from an eagerly-loaded using an + /// externally owned DryIoc container. + /// + /// The eagerly-loaded configuration. + /// Bootstrap options used to register core services. + /// Externally owned container that receives SquidStd services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdConfig config, SquidStdOptions options, IContainer container) + { + ArgumentNullException.ThrowIfNull(config); + + return new SquidStdBootstrap(options, container, false, config); + } + private static string ResolveAppName(SquidStdOptions options) => !string.IsNullOrWhiteSpace(options.AppName) ? options.AppName @@ -495,14 +544,11 @@ private void ApplyConfigHooks() return; } - List sections = Container.IsRegistered>() - ? Container.Resolve>() - : []; var logger = Log.ForContext(); foreach (var (configType, apply) in _configHooks) { - if (!sections.Any(section => section.ConfigType == configType)) + if (!Container.IsRegistered(configType)) { throw new InvalidOperationException( $"No config section registered for type '{configType.Name}'. " @@ -531,9 +577,9 @@ private void ApplyConfigHooks() private void LogRegistrations(ILogger logger, ServiceRegistrationData[] lifecycleRegistrations) { - List sections = Container.IsRegistered>() - ? Container.Resolve>() - : []; + IReadOnlyCollection sections = Container.IsRegistered() + ? Container.Resolve().Entries + : []; var containerRegistrations = Container.GetServiceRegistrations().ToArray(); logger.Information( diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index 7c95fa75..7c676c9e 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -1,57 +1,49 @@ -using System.Reflection; using DryIoc; -using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Interfaces.Services; -using SquidStd.Core.Extensions.Env; +using SquidStd.Core.Config; using SquidStd.Core.Interfaces.Config; -using SquidStd.Core.Yaml; namespace SquidStd.Services.Core.Services; /// -/// Loads YAML configuration sections and registers them into DryIoc. +/// Adapter exposing the standalone through +/// . Sections bind at registration time; +/// performs an explicit reload. /// public sealed class ConfigManagerService : IConfigManagerService, ISquidStdService { + private readonly SquidStdConfig _config; private readonly IContainer _container; - private readonly Dictionary _values = []; - private int _started; /// - public string ConfigName { get; } + public string ConfigName => _config.ConfigName; /// - public string ConfigDirectory { get; } + public string ConfigDirectory => _config.ConfigDirectory; /// - public string ConfigPath { get; } + public string ConfigPath => _config.ConfigPath; /// - public IReadOnlyCollection Entries => GetEntries(); + public IReadOnlyCollection Entries => _config.Entries; /// public event Action? ConfigLoaded; /// - /// Initializes the config manager service. + /// Initializes the adapter over the standalone configuration. /// - /// Container that receives loaded configuration sections. - /// Logical configuration name or YAML file name. - /// Directory where the configuration file is searched. - public ConfigManagerService(IContainer container, string configName, string configDirectory) + /// The eagerly-loaded configuration. + /// Container holding the bound section instances. + public ConfigManagerService(SquidStdConfig config, IContainer container) { - ArgumentException.ThrowIfNullOrWhiteSpace(configName); - ArgumentException.ThrowIfNullOrWhiteSpace(configDirectory); - + _config = config; _container = container; - ConfigName = configName; - ConfigDirectory = Path.GetFullPath(configDirectory); - ConfigPath = ResolveConfigPath(configName, ConfigDirectory); } /// public string Compose() - => YamlUtils.SerializeSections(BuildSectionMap()); + => _config.Compose(); /// public TConfig GetConfig() where TConfig : class @@ -60,28 +52,11 @@ public TConfig GetConfig() where TConfig : class /// public void Load() { - var entries = GetRegistrations(); - var yaml = File.Exists(ConfigPath) ? File.ReadAllText(ConfigPath) : string.Empty; - - _values.Clear(); + _config.Reload(); - for (var i = 0; i < entries.Count; i++) + foreach (var (_, configType, instance) in _config.BindAll()) { - var entry = entries[i]; - var value = string.IsNullOrWhiteSpace(yaml) - ? entry.CreateDefault() - : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? - entry.CreateDefault(); - - ApplyEnvSubstitution(value, new(ReferenceEqualityComparer.Instance)); - - _values[entry.ConfigType] = value; - _container.RegisterInstance(entry.ConfigType, value, IfAlreadyRegistered.Replace); - } - - if (!File.Exists(ConfigPath)) - { - Save(); + _container.RegisterInstance(configType, instance, IfAlreadyRegistered.Replace); } ConfigLoaded?.Invoke(); @@ -89,20 +64,13 @@ public void Load() /// public void Save() - => YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath); + => _config.Save(); /// public ValueTask StartAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - if (Interlocked.Exchange(ref _started, 1) != 0) - { - return ValueTask.CompletedTask; - } - - Load(); - return ValueTask.CompletedTask; } @@ -113,89 +81,4 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } - - 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 Dictionary BuildSectionMap() - { - var sections = new Dictionary(StringComparer.Ordinal); - var entries = GetRegistrations(); - - for (var i = 0; i < entries.Count; i++) - { - var entry = entries[i]; - - if (!_values.TryGetValue(entry.ConfigType, out var value)) - { - value = entry.CreateDefault(); - } - - sections[entry.SectionName] = value; - } - - return sections; - } - - private IReadOnlyCollection GetEntries() - => GetRegistrations().Cast().ToArray(); - - private List GetRegistrations() - { - if (!_container.IsRegistered>()) - { - return []; - } - - return - [ - .. _container.Resolve>() - .OrderBy(entry => entry.Priority) - .ThenBy(entry => entry.SectionName, StringComparer.Ordinal) - ]; - } - - private static string ResolveConfigPath(string configName, string configDirectory) - { - var fileName = Path.HasExtension(configName) ? configName : $"{configName}.yaml"; - - return Path.Combine(configDirectory, fileName); - } } diff --git a/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs b/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs index f22c1849..ee6e056e 100644 --- a/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs +++ b/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs @@ -25,7 +25,6 @@ public IContainer AddFileStorage(StorageConfig? config = null) else { container.RegisterConfigSection("storage", static () => new StorageConfig(), -70); - container.RegisterInstance(new StorageConfig(), IfAlreadyRegistered.Keep); } container.Register(Reuse.Singleton); diff --git a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs index 3a3da2dd..ddf8c34d 100644 --- a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs +++ b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs @@ -21,11 +21,19 @@ public static class WorkerManagerRegistrationExtensions /// Registers the worker manager: config section, registry, job scheduler, the collector and sweep /// lifecycle services, and the timer-wheel pump (only if it is not already registered). /// - public IContainer AddWorkerManager() + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. + public IContainer AddWorkerManager(WorkerManagerConfig? config = null) { ArgumentNullException.ThrowIfNull(container); - container.RegisterConfigSection("workerManager", static () => new WorkerManagerConfig(), -50); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("workerManager", static () => new WorkerManagerConfig(), -50); + } container.Register(Reuse.Singleton); container.RegisterMapping(); diff --git a/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs index 263b0723..b44c23e0 100644 --- a/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs +++ b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs @@ -31,11 +31,19 @@ public IContainer AddJobHandler() /// Registers the worker runtime: the "workers" config section, shared state, job dispatcher, and the /// consumer + heartbeat lifecycle services. /// - public IContainer AddWorkers() + /// Explicit configuration; when set, the YAML section is not bound and the file is ignored for this section. + public IContainer AddWorkers(WorkersConfig? config = null) { ArgumentNullException.ThrowIfNull(container); - container.RegisterConfigSection("workers", static () => new WorkersConfig(), -50); + if (config is not null) + { + container.RegisterInstance(config, IfAlreadyRegistered.Replace); + } + else + { + container.RegisterConfigSection("workers", static () => new WorkersConfig(), -50); + } container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); diff --git a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs b/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs deleted file mode 100644 index d55e3d23..00000000 --- a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using SquidStd.Abstractions.Data.Internal.Config; -using SquidStd.Tests.Support; - -namespace SquidStd.Tests.Abstractions; - -public class ConfigRegistrationDataTests -{ - [Fact] - public void CreateDefault_IncompatibleFactory_Throws() - { - var entry = new ConfigRegistrationData("test", typeof(TestConfig), () => new()); - - Assert.Throws(() => entry.CreateDefault()); - } - - [Fact] - public void CreateDefault_UsesFactory() - { - var entry = new ConfigRegistrationData( - "test", - typeof(TestConfig), - () => new TestConfig { Name = "default", Count = 7 }, - 3 - ); - - var config = Assert.IsType(entry.CreateDefault()); - - Assert.Equal("test", entry.SectionName); - Assert.Equal(typeof(TestConfig), entry.ConfigType); - Assert.Equal(3, entry.Priority); - Assert.Equal("default", config.Name); - Assert.Equal(7, config.Count); - } - - [Fact] - public void Ctor_InvalidSectionName_Throws() - => Assert.Throws( - () => new ConfigRegistrationData( - string.Empty, - typeof(TestConfig), - () => new TestConfig() - ) - ); - - [Fact] - public void Ctor_NullType_Throws() - => Assert.Throws(() => new ConfigRegistrationData("test", null!, () => new TestConfig())); -} diff --git a/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionEagerTests.cs b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionEagerTests.cs new file mode 100644 index 00000000..ed45f036 --- /dev/null +++ b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionEagerTests.cs @@ -0,0 +1,73 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Core.Config; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Abstractions; + +public class RegisterConfigSectionEagerTests +{ + public sealed class EagerSection + { + public string Name { get; set; } = "default"; + } + + [Fact] + public void RegisterConfigSection_WithSquidStdConfig_BindsImmediately() + { + using var root = new TempDirectory(); + File.WriteAllText(Path.Combine(root.Path, "app.yaml"), "eager:\n Name: fromfile\n"); + var config = SquidStdConfig.Load("app", root.Path); + using var container = new Container(); + container.RegisterInstance(config); + + container.RegisterConfigSection("eager", static () => new EagerSection()); + + Assert.Equal("fromfile", container.Resolve().Name); + Assert.Contains(config.Entries, entry => entry.SectionName == "eager"); + } + + [Fact] + public void RegisterConfigSection_ExplicitInstanceAlreadyRegistered_IsSkipped() + { + using var root = new TempDirectory(); + File.WriteAllText(Path.Combine(root.Path, "app.yaml"), "eager:\n Name: fromfile\n"); + var config = SquidStdConfig.Load("app", root.Path); + using var container = new Container(); + container.RegisterInstance(config); + var explicitInstance = new EagerSection { Name = "explicit" }; + container.RegisterInstance(explicitInstance); + + container.RegisterConfigSection("eager", static () => new EagerSection()); + + Assert.Same(explicitInstance, container.Resolve()); + Assert.DoesNotContain(config.Entries, entry => entry.SectionName == "eager"); + } + + [Fact] + public void RegisterConfigSection_TypeEagerlyTrackedUnderAnotherSection_Throws() + { + using var root = new TempDirectory(); + var config = SquidStdConfig.Load("app", root.Path); + using var container = new Container(); + container.RegisterInstance(config); + container.RegisterConfigSection("first", static () => new EagerSection()); + + Assert.Throws( + () => container.RegisterConfigSection("second") + ); + } + + [Fact] + public void RegisterConfigSection_WithoutSquidStdConfig_RegistersDefaultInstance() + { + using var container = new Container(); + + var exception = Record.Exception( + () => container.RegisterConfigSection("legacy", static () => new EagerSection()) + ); + + Assert.Null(exception); + Assert.Equal("default", container.Resolve().Name); + } +} diff --git a/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs index cb169580..3684d8c9 100644 --- a/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs +++ b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs @@ -1,14 +1,19 @@ using DryIoc; -using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Core.Config; using SquidStd.Tests.Support; namespace SquidStd.Tests.Abstractions; public class RegisterConfigSectionExtensionTests { + public sealed class AnotherConfig + { + public string Value { get; set; } = string.Empty; + } + [Fact] - public void RegisterConfigSection_AddsRegistrationData() + public void RegisterConfigSection_WithoutSquidStdConfig_RegistersDefaultFromFactory() { using var container = new Container(); @@ -18,55 +23,72 @@ public void RegisterConfigSection_AddsRegistrationData() 4 ); - var entry = Assert.Single(container.Resolve>()); - var config = Assert.IsType(entry.CreateDefault()); + var config = container.Resolve(); - Assert.Equal("test", entry.SectionName); - Assert.Equal(typeof(TestConfig), entry.ConfigType); - Assert.Equal(4, entry.Priority); Assert.Equal("default", config.Name); Assert.Equal(2, config.Count); - Assert.False(container.IsRegistered()); } [Fact] - public void RegisterConfigSection_DuplicateSectionWithDifferentType_Throws() + public void RegisterConfigSection_ReturnsSameContainerForChaining() { 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"); + var first = container.Resolve(); + container.RegisterConfigSection("test"); + var second = container.Resolve(); - Assert.Throws(() => container.RegisterConfigSection("test")); + Assert.Same(first, second); } [Fact] - public void RegisterConfigSection_DuplicateTypeWithDifferentSection_Throws() + public void RegisterConfigSection_DuplicateTypeDifferentSectionWithoutSquidStdConfig_IsSkipped() { using var container = new Container(); container.RegisterConfigSection("first"); - Assert.Throws(() => container.RegisterConfigSection("second")); + var exception = Record.Exception(() => container.RegisterConfigSection("second")); + + Assert.Null(exception); } [Fact] - public void RegisterConfigSection_ReturnsSameContainerForChaining() + public void RegisterConfigSection_DuplicateSectionDifferentTypeWithoutSquidStdConfig_RegistersBothDefaults() { using var container = new Container(); - var result = container.RegisterConfigSection("test"); + container.RegisterConfigSection("dup", static () => new TestConfig()); + container.RegisterConfigSection("dup", static () => new AnotherConfig()); - Assert.Same(container, result); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); } [Fact] - public void RegisterConfigSection_SameTypeAndSection_IsIdempotent() + public void RegisterConfigSection_SameTypeAndSectionWithSquidStdConfig_IsIdempotent() { + using var root = new TempDirectory(); + var config = SquidStdConfig.Load("app", root.Path); using var container = new Container(); + container.RegisterInstance(config); - container.RegisterConfigSection("test"); - container.RegisterConfigSection("test"); + container.RegisterConfigSection("sample", static () => new TestConfig()); + var first = container.Resolve(); + container.RegisterConfigSection("sample", static () => new TestConfig()); - Assert.Single(container.Resolve>()); + Assert.Same(first, container.Resolve()); } } diff --git a/tests/SquidStd.Tests/Bootstrap/ConfigFirstBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/ConfigFirstBootstrapTests.cs new file mode 100644 index 00000000..467f3f83 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/ConfigFirstBootstrapTests.cs @@ -0,0 +1,128 @@ +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Core.Config; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +public class ConfigFirstBootstrapTests +{ + public sealed class HostSection + { + public string Mode { get; set; } = "default"; + } + + [Fact] + public async Task Create_WithExplicitConfig_SectionsBindAtRegistration() + { + using var root = new TempDirectory(); + File.WriteAllText(root.Combine("app.yaml"), "host:\n Mode: fromfile\n"); + var config = SquidStdConfig.Load("app", root.Path); + + await using var bootstrap = + SquidStdBootstrap.Create(config, new SquidStdOptions { ConfigName = "app", RootDirectory = root.Path }); + + bootstrap.ConfigureServices(c => + { + c.RegisterConfigSection("host", static () => new HostSection()); + return c; + }); + + // bound IMMEDIATELY, before any StartAsync + Assert.Equal("fromfile", bootstrap.Resolve().Mode); + } + + [Fact] + public async Task OnConfigLoaded_AppliesOnceAtStart_AndSticks() + { + using var root = new TempDirectory(); + var applied = 0; + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "hooktest", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterConfigSection("host", static () => new HostSection()); + return c; + }); + bootstrap.OnConfigLoaded(section => + { + applied++; + section.Mode = "hooked"; + }); + + await bootstrap.StartAsync(); + + Assert.Equal(1, applied); + Assert.Equal("hooked", bootstrap.Resolve().Mode); + await bootstrap.StopAsync(); + } + + [Fact] + public async Task StartAsync_MissingFile_WritesDefaults() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "writedefaults", RootDirectory = root.Path } + ); + + await bootstrap.StartAsync(); + + Assert.True(File.Exists(root.Combine("writedefaults.yaml"))); + await bootstrap.StopAsync(); + } + + [Fact] + public async Task ExplicitLoad_Reloads_FiresEvent_AndReappliesHooks() + { + using var root = new TempDirectory(); + File.WriteAllText(root.Combine("reload.yaml"), "host:\n Mode: v1\n"); + var hookRuns = 0; + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "reload", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterConfigSection("host", static () => new HostSection()); + return c; + }); + bootstrap.OnConfigLoaded(_ => hookRuns++); + + await bootstrap.StartAsync(); + Assert.Equal(1, hookRuns); + + File.WriteAllText(root.Combine("reload.yaml"), "host:\n Mode: v2\n"); + var manager = bootstrap.Resolve(); + manager.Load(); + + Assert.Equal(2, hookRuns); + Assert.Equal("v2", bootstrap.Resolve().Mode); + await bootstrap.StopAsync(); + } + + [Fact] + public async Task OptionsLogger_BypassesTheFileSection() + { + using var root = new TempDirectory(); + File.WriteAllText(root.Combine("logopt.yaml"), "logger:\n MinimumLevel: Error\n"); + var explicitLogger = new SquidStdLoggerOptions { MinimumLevel = LogLevelType.Debug }; + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "logopt", RootDirectory = root.Path, Logger = explicitLogger } + ); + + await bootstrap.StartAsync(); + + Assert.Same(explicitLogger, bootstrap.Resolve()); + Assert.Equal(LogLevelType.Debug, bootstrap.Resolve().MinimumLevel); + await bootstrap.StopAsync(); + } +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapConfigHooksTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapConfigHooksTests.cs index 62d7fb7d..5d4dabe9 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapConfigHooksTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapConfigHooksTests.cs @@ -35,38 +35,11 @@ public async Task OnConfigLoaded_MutatesSection_VisibleAfterStart() } } - [Fact] - public async Task OnConfigLoaded_SurvivesTheSecondLoad() - { - using var temp = new TempDirectory(); - await using var bootstrap = NewBootstrap(temp.Path); - var invocations = 0; - - bootstrap.ConfigureServices( - container => container.RegisterConfigSection("fakeSection", static () => new FakeSectionConfig(), 0) - ); - bootstrap.OnConfigLoaded( - fake => - { - invocations++; - fake.Limit = 42; - } - ); - - await bootstrap.StartAsync(CancellationToken.None); - - try - { - var config = bootstrap.Resolve().GetConfig(); - - Assert.True(invocations >= 2, $"Expected the hook to run on every load, but it ran {invocations} time(s)."); - Assert.Equal(42, config.Limit); - } - finally - { - await bootstrap.StopAsync(CancellationToken.None); - } - } + // OnConfigLoaded_SurvivesTheSecondLoad was removed: under the config-first contract there is + // no implicit second load during StartAsync. Its coverage is now split between + // ConfigFirstBootstrapTests.OnConfigLoaded_AppliesOnceAtStart_AndSticks (hooks apply exactly + // once at start) and ConfigFirstBootstrapTests.ExplicitLoad_Reloads_FiresEvent_AndReappliesHooks + // (hooks re-apply on an explicit IConfigManagerService.Load() reload). [Fact] public async Task OnConfigLoaded_LoggerSection_AffectsLoggerConfiguration() diff --git a/tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs b/tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs index 0e436d92..207c67fe 100644 --- a/tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs +++ b/tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs @@ -1,15 +1,37 @@ using DryIoc; using Serilog.Core; +using SquidStd.ConsoleCommands.Data.Config; using SquidStd.ConsoleCommands.Extensions; using SquidStd.ConsoleCommands.Interfaces; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Config; using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; using SquidStd.Tests.Support; namespace SquidStd.Tests.ConsoleCommands; public class ConsoleCommandsRegistrationTests { + [Fact] + public async Task AddConsoleCommands_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new ConsoleCommandsConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "consolecommands", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.AddConsoleCommands(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + [Fact] public void AddConsoleCommands_RegistersTheStack() { diff --git a/tests/SquidStd.Tests/Core/Config/SquidStdConfigTests.cs b/tests/SquidStd.Tests/Core/Config/SquidStdConfigTests.cs new file mode 100644 index 00000000..235c45e0 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Config/SquidStdConfigTests.cs @@ -0,0 +1,173 @@ +using SquidStd.Core.Config; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Core.Config; + +public class SquidStdConfigTests +{ + public sealed class SampleSection + { + public string Name { get; set; } = "default"; + + public int Value { get; set; } = 1; + } + + public sealed class OtherSection + { + public bool Enabled { get; set; } + } + + private static SquidStdConfig CreateWithYaml(TempDirectory root, string yaml) + { + File.WriteAllText(Path.Combine(root.Path, "app.yaml"), yaml); + + return SquidStdConfig.Load("app", root.Path); + } + + [Fact] + public void Load_MissingFile_YieldsDefaultsAndDoesNotCreateIt() + { + using var root = new TempDirectory(); + + var config = SquidStdConfig.Load("app", root.Path); + var section = config.GetSection("sample"); + + Assert.Equal("default", section.Name); + Assert.False(File.Exists(config.ConfigPath)); + } + + [Fact] + public void GetSection_BindsFromYaml_AndCachesSameInstance() + { + using var root = new TempDirectory(); + var config = CreateWithYaml(root, "sample:\n Name: fromfile\n Value: 42\n"); + + var first = config.GetSection("sample"); + var second = config.GetSection("sample"); + + Assert.Equal("fromfile", first.Name); + Assert.Equal(42, first.Value); + Assert.Same(first, second); + } + + [Fact] + public void GetSection_AppliesEnvSubstitution() + { + using var root = new TempDirectory(); + Environment.SetEnvironmentVariable("SQUIDSTD_TEST_NAME", "resolved"); + + try + { + var config = CreateWithYaml(root, "sample:\n Name: $SQUIDSTD_TEST_NAME\n"); + + Assert.Equal("resolved", config.GetSection("sample").Name); + } + finally + { + Environment.SetEnvironmentVariable("SQUIDSTD_TEST_NAME", null); + } + } + + [Fact] + public void GetSection_SameNameDifferentType_Throws() + { + using var root = new TempDirectory(); + var config = CreateWithYaml(root, "sample:\n Name: x\n"); + + config.GetSection("sample"); + + Assert.Throws(() => config.GetSection("sample")); + } + + [Fact] + public void HasSection_ReflectsRawDocument() + { + using var root = new TempDirectory(); + var config = CreateWithYaml(root, "sample:\n Name: x\n"); + + Assert.True(config.HasSection("sample")); + Assert.False(config.HasSection("ghost")); + } + + [Fact] + public void Reload_ClearsBindCache_AndRebindsFreshValues() + { + using var root = new TempDirectory(); + var config = CreateWithYaml(root, "sample:\n Name: before\n"); + var before = config.GetSection("sample"); + + File.WriteAllText(config.ConfigPath, "sample:\n Name: after\n"); + config.Reload(); + var after = config.GetSection("sample"); + + Assert.NotSame(before, after); + Assert.Equal("after", after.Name); + } + + [Fact] + public void BindAll_BindsEveryTrackedSection() + { + using var root = new TempDirectory(); + var config = CreateWithYaml(root, "sample:\n Value: 7\n"); + config.TrackSection("sample", typeof(SampleSection), 0, static () => new SampleSection()); + config.TrackSection("other", typeof(OtherSection), -10, static () => new OtherSection()); + + var bound = config.BindAll(); + + Assert.Equal(2, bound.Count); + Assert.Equal(7, ((SampleSection)bound.Single(b => b.SectionName == "sample").Instance).Value); + } + + [Fact] + public void TrackSection_DuplicateSameNameAndType_IsIdempotent_DifferentTypeThrows() + { + using var root = new TempDirectory(); + var config = SquidStdConfig.Load("app", root.Path); + + config.TrackSection("sample", typeof(SampleSection), 0, static () => new SampleSection()); + config.TrackSection("sample", typeof(SampleSection), 0, static () => new SampleSection()); + + Assert.Single(config.Entries); + Assert.Throws( + () => config.TrackSection("sample", typeof(OtherSection), 0, static () => new OtherSection()) + ); + Assert.Throws( + () => config.TrackSection("sample2", typeof(SampleSection), 0, static () => new SampleSection()) + ); + } + + [Fact] + public void ComposeAndSave_CoverBoundAndTrackedUnboundSections() + { + using var root = new TempDirectory(); + var config = CreateWithYaml(root, "sample:\n Name: fromfile\n"); + config.GetSection("sample"); + config.TrackSection("other", typeof(OtherSection), 10, static () => new OtherSection()); + + var composed = config.Compose(); + + Assert.Contains("fromfile", composed); + Assert.Contains("other", composed); + + config.Save(); + Assert.Contains("other", File.ReadAllText(config.ConfigPath)); + } + + [Fact] + public void Load_ResolvesTildeAndEnvInDirectory() + { + using var root = new TempDirectory(); + Environment.SetEnvironmentVariable("SQUIDSTD_TEST_DIR", root.Path); + + try + { + var config = SquidStdConfig.Load("app", "$SQUIDSTD_TEST_DIR"); + + Assert.Equal(Path.GetFullPath(root.Path), config.ConfigDirectory); + } + finally + { + Environment.SetEnvironmentVariable("SQUIDSTD_TEST_DIR", null); + } + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/RegisterLuaEngineExtensionTests.cs b/tests/SquidStd.Tests/Scripting/Lua/RegisterLuaEngineExtensionTests.cs new file mode 100644 index 00000000..c638a579 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/RegisterLuaEngineExtensionTests.cs @@ -0,0 +1,25 @@ +using DryIoc; +using SquidStd.Scripting.Lua.Data.Config; +using SquidStd.Scripting.Lua.Extensions.Scripts; +using SquidStd.Scripting.Lua.Interfaces.Scripts; + +namespace SquidStd.Tests.Scripting.Lua; + +public class RegisterLuaEngineExtensionTests +{ + [Fact] + public void RegisterLuaEngine_RegistersConfigAndService() + { + using var container = new Container(); + var config = new LuaEngineConfig(AppContext.BaseDirectory, AppContext.BaseDirectory, "tests", "1.0.0"); + + container.RegisterLuaEngine(config); + + Assert.Same(config, container.Resolve()); + Assert.True(container.IsRegistered()); + } + + [Fact] + public void RegisterLuaEngine_NullConfig_Throws() + => Assert.Throws(() => new Container().RegisterLuaEngine(null!)); +} diff --git a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs index 7cc5fab2..6e2827e3 100644 --- a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs +++ b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs @@ -1,5 +1,6 @@ using DryIoc; using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Core.Config; using SquidStd.Database.Abstractions.Data.Database; using SquidStd.Services.Core.Services; @@ -8,8 +9,11 @@ namespace SquidStd.Tests.Services.Core; public class ConfigManagerServiceEnvTests { [Fact] - public void Load_SubstitutesEnvTokensInStringProperties() + public void Ctor_SubstitutesEnvTokensInStringProperties() { + // Section binding is eager once the SquidStdConfig instance is registered into the + // container, so the substitution is already applied by the time RegisterConfigSection + // returns - there is no separate Load() step to trigger it anymore. var dir = Path.Combine(Path.GetTempPath(), "squidstd-cfg-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); Environment.SetEnvironmentVariable("SQUID_DB_PASS", "p@ss"); @@ -22,13 +26,12 @@ public void Load_SubstitutesEnvTokensInStringProperties() ); var container = new Container(); + var config = SquidStdConfig.Load("app", dir); + container.RegisterInstance(config, IfAlreadyRegistered.Replace); container.RegisterConfigSection("database"); - var service = new ConfigManagerService(container, "app", dir); + var service = new ConfigManagerService(config, container); - service.Load(); - - var config = container.Resolve(); - Assert.Equal("postgres://u:p@ss@h:5432/db", config.ConnectionString); + Assert.Equal("postgres://u:p@ss@h:5432/db", service.GetConfig().ConnectionString); } finally { diff --git a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs index db670d1a..1d9d6b9e 100644 --- a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs @@ -1,5 +1,6 @@ using DryIoc; using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Core.Config; using SquidStd.Core.Interfaces.Config; using SquidStd.Services.Core.Services; using SquidStd.Tests.Support; @@ -12,26 +13,30 @@ public class ConfigManagerServiceTests public void ConfigPath_AppendsYamlExtension() { using var container = new Container(); - IConfigManagerService manager = new ConfigManagerService(container, "app", "/tmp/config"); + var config = SquidStdConfig.Load("app", "/tmp/config"); + IConfigManagerService manager = new ConfigManagerService(config, container); Assert.Equal(Path.Combine("/tmp/config", "app.yaml"), manager.ConfigPath); } [Fact] - public async Task GetConfig_ReturnsRegisteredSectionAfterStart() + public void GetConfig_ReturnsSectionBoundAtRegistration() { + // Under the config-first contract, RegisterConfigSection binds eagerly as soon as a + // SquidStdConfig instance is registered into the container - there is no separate + // "start" step to wait for. using var temp = new TempDirectory(); using var container = new Container(); + var config = SquidStdConfig.Load("app", temp.Path); + container.RegisterInstance(config, IfAlreadyRegistered.Replace); container.RegisterConfigSection("test"); - IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); - - await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); + IConfigManagerService manager = new ConfigManagerService(config, container); Assert.Same(container.Resolve(), manager.GetConfig()); } [Fact] - public async Task StartAsync_ExistingFile_LoadsAndRegistersSection() + public void Ctor_ExistingFile_BindsSectionEagerlyFromFile() { using var temp = new TempDirectory(); File.WriteAllText( @@ -43,37 +48,39 @@ public async Task StartAsync_ExistingFile_LoadsAndRegistersSection() """ ); using var container = new Container(); + var config = SquidStdConfig.Load("app", temp.Path); + container.RegisterInstance(config, IfAlreadyRegistered.Replace); container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); - IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); - - await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); + IConfigManagerService manager = new ConfigManagerService(config, container); - var config = container.Resolve(); - Assert.Equal("loaded", config.Name); - Assert.Equal(9, config.Count); + var result = manager.GetConfig(); + Assert.Equal("loaded", result.Name); + Assert.Equal(9, result.Count); } [Fact] - public async Task StartAsync_MissingFile_CreatesDefaultFileAndRegistersSection() + public void Save_MissingFile_CreatesDefaultFileWithRegisteredSection() { using var temp = new TempDirectory(); using var container = new Container(); + var config = SquidStdConfig.Load("app", temp.Path); + container.RegisterInstance(config, IfAlreadyRegistered.Replace); container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); - IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); + IConfigManagerService manager = new ConfigManagerService(config, container); - await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); + manager.Save(); var path = Path.Combine(temp.Path, "app.yaml"); - var config = container.Resolve(); + var result = manager.GetConfig(); Assert.True(File.Exists(path)); - Assert.Equal("default", config.Name); - Assert.Equal(3, config.Count); + Assert.Equal("default", result.Name); + Assert.Equal(3, result.Count); Assert.Contains("test:", File.ReadAllText(path)); } [Fact] - public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() + public void Save_MissingSection_UsesDefaultAndOmitsUntrackedSections() { using var temp = new TempDirectory(); File.WriteAllText( @@ -84,17 +91,18 @@ public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() """ ); using var container = new Container(); + var config = SquidStdConfig.Load("app", temp.Path); + container.RegisterInstance(config, IfAlreadyRegistered.Replace); container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); - IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); + IConfigManagerService manager = new ConfigManagerService(config, container); - await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); manager.Save(); - var config = container.Resolve(); + var result = manager.GetConfig(); var yaml = File.ReadAllText(temp.Combine("app.yaml")); - Assert.Equal("default", config.Name); - Assert.Equal(3, config.Count); + Assert.Equal("default", result.Name); + Assert.Equal(3, result.Count); Assert.Contains("test:", yaml); Assert.DoesNotContain("other:", yaml); } diff --git a/tests/SquidStd.Tests/Services/Core/ExplicitConfigRegistrationTests.cs b/tests/SquidStd.Tests/Services/Core/ExplicitConfigRegistrationTests.cs new file mode 100644 index 00000000..e22ba43a --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/ExplicitConfigRegistrationTests.cs @@ -0,0 +1,155 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Data.Config; +using SquidStd.Core.Data.EventLoop; +using SquidStd.Core.Data.Jobs; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Data.Timing; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Services.Core; + +public class ExplicitConfigRegistrationTests +{ + [Fact] + public async Task RegisterEventLoop_ExplicitConfig_SurvivesStart_AndIgnoresFile() + { + using var root = new TempDirectory(); + File.WriteAllText(root.Combine("explicit.yaml"), "eventLoop:\n IdleSleepMs: 99\n"); + var explicitConfig = new EventLoopConfig { IdleSleepMs = 3 }; + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "explicit", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterMainThreadDispatcherService(); + c.RegisterTimerWheelService(); + c.RegisterEventLoop(explicitConfig); + return c; + }); + + await bootstrap.StartAsync(); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + Assert.Equal(3, bootstrap.Resolve().IdleSleepMs); + await bootstrap.StopAsync(); + } + + [Fact] + public async Task RegisterSchedulerServices_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new TimerWheelPumpConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "scheduler", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterSchedulerServices(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + + [Fact] + public async Task RegisterTimerWheelService_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new TimerWheelConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "timerwheel", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterTimerWheelService(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + + [Fact] + public async Task RegisterJobSystemService_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new JobsConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "jobs", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterJobSystemService(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + + [Fact] + public async Task RegisterMetricsCollectionService_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new MetricsConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "metrics", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterMetricsCollectionService(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + + [Fact] + public async Task RegisterSecretServices_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new SecretsConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "secrets", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterSecretServices(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + + [Fact] + public async Task RegisterHealthChecksService_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new HealthCheckOptions(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "health", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterHealthChecksService(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index 42d53edf..ef7c3626 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -1,6 +1,6 @@ using DryIoc; -using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Data.Internal.Services; +using SquidStd.Core.Config; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; @@ -119,65 +119,61 @@ public async Task RegisterCoreServices_StartsConfigManagerBeforeResolvingConfigC } [Fact] - public void RegisterJobSystemAndTimerWheelServices_RegisterJobsAndTimerWheelMetadata() + public void RegisterJobSystemAndTimerWheelServices_RegisterDefaultConfigInstances() { using var container = new Container(); container.RegisterJobSystemService(); container.RegisterTimerWheelService(); - 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()); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); } [Fact] - public void RegisterConfigServices_RegistersLoggerMetadata() + public void RegisterConfigServices_BindsLoggerSectionEagerly() { + // RegisterConfigServices registers a SquidStdConfig instance before touching the "logger" + // section, so RegisterConfigSection binds it against SquidStdConfig eagerly: the section + // is bound and resolvable immediately, with no separate load/start step. using var temp = new TempDirectory(); using var container = new Container(); container.RegisterConfigServices("app", temp.Path); - var entries = container.Resolve>(); + Assert.True(container.IsRegistered()); + Assert.NotNull(container.Resolve()); + + var entries = container.Resolve().Entries; Assert.Contains( entries, entry => entry.SectionName == "logger" && entry.ConfigType == typeof(SquidStdLoggerOptions) ); - Assert.False(container.IsRegistered()); } [Fact] - public void RegisterMetricsCollectionService_RegistersMetricsMetadata() + public void RegisterMetricsCollectionService_RegistersDefaultConfigInstance() { using var container = new Container(); container.RegisterMetricsCollectionService(); - var entries = container.Resolve>(); - - Assert.Contains(entries, entry => entry.SectionName == "metrics" && entry.ConfigType == typeof(MetricsConfig)); - Assert.False(container.IsRegistered()); + Assert.True(container.IsRegistered()); } [Fact] - public void AddFileStorageAndSecretServices_RegisterStorageAndSecretsMetadata() + public void AddFileStorageAndSecretServices_RegisterDefaultConfigInstances() { using var container = new Container(); container.AddFileStorage(); container.RegisterSecretServices(); - 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)); + // Bare container (no SquidStdConfig): RegisterConfigSection's degenerate fallback + // registers the default instance directly instead of deferring to a config manager. Assert.True(container.IsRegistered()); - Assert.False(container.IsRegistered()); + Assert.True(container.IsRegistered()); } [Fact] diff --git a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs index a7d7f88c..0c4edf6b 100644 --- a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs +++ b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs @@ -1,6 +1,10 @@ using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Storage.Extensions; +using SquidStd.Tests.Support; namespace SquidStd.Tests.Storage; @@ -24,4 +28,23 @@ public async Task AddFileStorage_ResolvesAndRoundTrips() Directory.Delete(root, true); } + + [Fact] + public async Task AddFileStorage_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new StorageConfig { RootDirectory = root.Combine("data") }; + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "storage", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.AddFileStorage(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } } diff --git a/tests/SquidStd.Tests/Workers/ExplicitWorkersConfigTests.cs b/tests/SquidStd.Tests/Workers/ExplicitWorkersConfigTests.cs new file mode 100644 index 00000000..8ab254e4 --- /dev/null +++ b/tests/SquidStd.Tests/Workers/ExplicitWorkersConfigTests.cs @@ -0,0 +1,78 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Data.Timing; +using SquidStd.Messaging.Extensions; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; +using SquidStd.Workers.Data.Config; +using SquidStd.Workers.Extensions; +using SquidStd.Workers.Manager.Data.Config; +using SquidStd.Workers.Manager.Extensions; + +namespace SquidStd.Tests.Workers; + +public class ExplicitWorkersConfigTests +{ + [Fact] + public async Task AddWorkers_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new WorkersConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "workers", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.AddWorkers(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + + [Fact] + public async Task AddWorkerManager_ExplicitConfig_IsResolved() + { + using var root = new TempDirectory(); + var explicitConfig = new WorkerManagerConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "workermanager", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.AddWorkerManager(explicitConfig); + return c; + }); + + Assert.Same(explicitConfig, bootstrap.Resolve()); + } + + [Fact] + public async Task ExplicitPumpConfig_SurvivesWorkerManagerAutoRegistration() + { + using var root = new TempDirectory(); + var pump = new TimerWheelPumpConfig(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "pumpguard", RootDirectory = root.Path } + ); + + bootstrap.ConfigureServices(c => + { + c.RegisterCoreServices(); + c.AddInMemoryMessaging(); + c.RegisterSchedulerServices(pump); + c.AddWorkerManager(); // auto-registers the timerWheelPump section + return c; + }); + + await bootstrap.StartAsync(); + + Assert.Same(pump, bootstrap.Resolve()); + await bootstrap.StopAsync(); + } +}