diff --git a/SquidStd.slnx b/SquidStd.slnx
index 9e3564e7..3dfb51c6 100644
--- a/SquidStd.slnx
+++ b/SquidStd.slnx
@@ -23,6 +23,7 @@
+
diff --git a/docs/articles/plugin.md b/docs/articles/plugin.md
new file mode 100644
index 00000000..589956b6
--- /dev/null
+++ b/docs/articles/plugin.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Plugin/README.md)]
diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml
index 14ced565..ba8e7e9d 100644
--- a/docs/articles/toc.yml
+++ b/docs/articles/toc.yml
@@ -46,6 +46,8 @@
href: network.md
- name: SquidStd.Plugin.Abstractions
href: plugin-abstractions.md
+- name: SquidStd.Plugin
+ href: plugin.md
- name: SquidStd.Database.Abstractions
href: database-abstractions.md
- name: SquidStd.Database
diff --git a/docs/tutorials/plugins.md b/docs/tutorials/plugins.md
index 1cff0815..24244d78 100644
--- a/docs/tutorials/plugins.md
+++ b/docs/tutorials/plugins.md
@@ -52,3 +52,4 @@ one plugin by hand so the contract is fully runnable without external assemblies
## See also
- [SquidStd.Plugin.Abstractions reference](../articles/plugin-abstractions.md)
+- [SquidStd.Plugin reference](../articles/plugin.md) - the loader that discovers and configures plugins in dependency order
diff --git a/samples/SquidStd.Samples.Plugins/Program.cs b/samples/SquidStd.Samples.Plugins/Program.cs
index 5822c920..56610617 100644
--- a/samples/SquidStd.Samples.Plugins/Program.cs
+++ b/samples/SquidStd.Samples.Plugins/Program.cs
@@ -1,6 +1,9 @@
using DryIoc;
+using SquidStd.Core.Data.Bootstrap;
using SquidStd.Plugin.Abstractions.Data;
using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+using SquidStd.Plugin.Extensions;
+using SquidStd.Services.Core.Services.Bootstrap;
namespace SquidStd.Samples.Plugins;
@@ -41,16 +44,16 @@ private static void Main()
{
#region step-2
- var container = new Container();
- var context = new PluginContext();
- context.Data["startedAt"] = DateTimeOffset.UtcNow;
-
- ISquidStdPlugin plugin = new WeatherPlugin();
+ var bootstrap = SquidStdBootstrap.Create(
+ new SquidStdOptions { ConfigName = "plugins-sample", RootDirectory = Directory.GetCurrentDirectory() }
+ );
+ var plugin = new WeatherPlugin();
Console.WriteLine($"Loading {plugin.Metadata.Name} v{plugin.Metadata.Version} by {plugin.Metadata.Author}");
- plugin.Configure(container, context);
- var greeter = container.Resolve();
+ bootstrap.UsePlugins(plugins => plugins.Add(plugin));
+
+ var greeter = bootstrap.Resolve();
Console.WriteLine(greeter.Greet("squid"));
#endregion
diff --git a/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj
index 900e30a0..665d155e 100644
--- a/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj
+++ b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs
index ebaa0bdc..1e6639f2 100644
--- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs
+++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs
@@ -1,6 +1,7 @@
using DryIoc;
using SquidStd.Core.Data.Bootstrap;
using SquidStd.Core.Interfaces.Config;
+using SquidStd.Core.Types.Bootstrap;
namespace SquidStd.Core.Interfaces.Bootstrap;
@@ -19,6 +20,12 @@ public interface ISquidStdBootstrap : IAsyncDisposable
///
IContainer Container { get; }
+ ///
+ /// Gets the current lifecycle state of the bootstrap. Reads are lock-free and intended for
+ /// pre-start guards, not for synchronizing concurrent lifecycle transitions.
+ ///
+ BootstrapStateType State { get; }
+
///
/// Applies custom service registrations before the bootstrap lifecycle starts.
///
diff --git a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs b/src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs
similarity index 79%
rename from src/SquidStd.Services.Core/Types/BootstrapStateType.cs
rename to src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs
index 2b1d72ff..1b58d254 100644
--- a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs
+++ b/src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs
@@ -1,9 +1,9 @@
-namespace SquidStd.Services.Core.Types;
+namespace SquidStd.Core.Types.Bootstrap;
///
/// Lifecycle state of the SquidStd bootstrapper.
///
-internal enum BootstrapStateType
+public enum BootstrapStateType
{
/// Created but not started.
Created,
diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs
new file mode 100644
index 00000000..b05d486d
--- /dev/null
+++ b/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs
@@ -0,0 +1,18 @@
+namespace SquidStd.Plugin.Abstractions.Data;
+
+///
+/// Well-known keys the SquidStd plugin loader populates in .
+///
+public static class PluginContextKeys
+{
+ ///
+ /// Application root directory (string): the bootstrap RootDirectory option.
+ ///
+ public const string RootDirectory = "squidstd:rootDirectory";
+
+ ///
+ /// Application name (string): the bootstrap AppName option when set, otherwise the entry
+ /// assembly name, otherwise the bootstrap ConfigName.
+ ///
+ public const string AppName = "squidstd:appName";
+}
diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs
index eeffa2f3..b365bddc 100644
--- a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs
+++ b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs
@@ -1,11 +1,11 @@
namespace SquidStd.Plugin.Abstractions.Data;
///
-/// Describes a Moongate plugin. This is the source of truth for plugin identity.
+/// Describes a SquidStd plugin. This is the source of truth for plugin identity.
///
public sealed class PluginMetadata
{
- /// Stable lowercase dotted plugin identifier, for example moongate.weather.
+ /// Stable lowercase dotted plugin identifier, for example myapp.weather.
public required string Id { get; init; }
/// Human-readable plugin name.
diff --git a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs
index d761f5a0..f09c6227 100644
--- a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs
+++ b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs
@@ -4,7 +4,7 @@
namespace SquidStd.Plugin.Abstractions.Interfaces.Plugins;
///
-/// Implemented by trusted .NET plugins loaded by Moongate during server startup.
+/// Implemented by trusted .NET plugins loaded by the SquidStd plugin loader during application startup.
///
public interface ISquidStdPlugin
{
diff --git a/src/SquidStd.Plugin.Abstractions/README.md b/src/SquidStd.Plugin.Abstractions/README.md
index 4506a924..09eb5adc 100644
--- a/src/SquidStd.Plugin.Abstractions/README.md
+++ b/src/SquidStd.Plugin.Abstractions/README.md
@@ -45,6 +45,7 @@ public sealed class MyPlugin : ISquidStdPlugin
## Related
- Tutorial: [Plugins](https://tgiachi.github.io/squid-std/tutorials/plugins.html)
+- Loader: these contracts are loaded by the [SquidStd.Plugin](https://tgiachi.github.io/squid-std/articles/plugin.html) package via `UsePlugins`.
## License
diff --git a/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs b/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs
new file mode 100644
index 00000000..88628cec
--- /dev/null
+++ b/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs
@@ -0,0 +1,15 @@
+namespace SquidStd.Plugin.Exceptions;
+
+///
+/// Raised when the plugin loader cannot discover, order, or instantiate plugins.
+///
+public class PluginLoadException : Exception
+{
+ /// Initializes the exception with a message describing the failure.
+ public PluginLoadException(string message)
+ : base(message) { }
+
+ /// Initializes the exception with a message and the underlying cause.
+ public PluginLoadException(string message, Exception innerException)
+ : base(message, innerException) { }
+}
diff --git a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs
new file mode 100644
index 00000000..9de57327
--- /dev/null
+++ b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs
@@ -0,0 +1,87 @@
+using System.Reflection;
+using Serilog;
+using SquidStd.Core.Interfaces.Bootstrap;
+using SquidStd.Core.Types.Bootstrap;
+using SquidStd.Plugin.Abstractions.Data;
+using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+using SquidStd.Plugin.Internal;
+
+namespace SquidStd.Plugin.Extensions;
+
+///
+/// Connects the plugin loader to the SquidStd bootstrap.
+///
+public static class SquidStdBootstrapPluginExtensions
+{
+ ///
+ /// Collects internal plugins and external plugin directories, resolves the dependency order
+ /// across the whole set, and invokes for each plugin
+ /// in order against the bootstrap container. Must be called before the bootstrap starts, so
+ /// plugins can register configuration sections before the configuration is loaded. Relative
+ /// directories are resolved against the bootstrap root directory. Any failure aborts startup:
+ /// loader problems raise and plugin exceptions
+ /// propagate unchanged. Plugin assemblies load into the default AssemblyLoadContext and are
+ /// fully trusted: there is no unloading and no version isolation. Note that plugin load
+ /// logging is emitted before the bootstrap configures Serilog, so it is only visible when
+ /// a logger is configured beforehand.
+ ///
+ /// The bootstrap to configure.
+ /// Callback that registers plugins and directories.
+ /// The same bootstrap for chaining.
+ public static ISquidStdBootstrap UsePlugins(
+ this ISquidStdBootstrap bootstrap,
+ Action configure
+ )
+ {
+ ArgumentNullException.ThrowIfNull(bootstrap);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ if (bootstrap.State != BootstrapStateType.Created)
+ {
+ throw new InvalidOperationException(
+ "Plugins must be registered before the bootstrap starts."
+ );
+ }
+
+ var logger = Log.ForContext(typeof(SquidStdBootstrapPluginExtensions));
+ var builder = new PluginCollectionBuilder();
+
+ configure(builder);
+
+ var plugins = new List(builder.Plugins);
+
+ foreach (var directory in builder.Directories)
+ {
+ var resolved = Path.IsPathRooted(directory)
+ ? directory
+ : Path.Combine(bootstrap.Options.RootDirectory, directory);
+
+ plugins.AddRange(PluginAssemblyScanner.Scan(resolved));
+ }
+
+ var ordered = PluginDependencyResolver.Sort(plugins);
+ var appName = !string.IsNullOrWhiteSpace(bootstrap.Options.AppName)
+ ? bootstrap.Options.AppName
+ : Assembly.GetEntryAssembly()?.GetName().Name ?? bootstrap.Options.ConfigName;
+
+ foreach (var plugin in ordered)
+ {
+ var context = new PluginContext();
+ context.Data[PluginContextKeys.RootDirectory] = bootstrap.Options.RootDirectory;
+ context.Data[PluginContextKeys.AppName] = appName;
+
+ plugin.Configure(bootstrap.Container, context);
+
+ logger.Information(
+ "Loaded plugin {PluginId:l} v{PluginVersion} by {PluginAuthor:l}",
+ plugin.Metadata.Id,
+ plugin.Metadata.Version,
+ plugin.Metadata.Author
+ );
+ }
+
+ logger.Information("{PluginCount} plugin(s) loaded", ordered.Count);
+
+ return bootstrap;
+ }
+}
diff --git a/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs b/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs
new file mode 100644
index 00000000..c3a5d8a0
--- /dev/null
+++ b/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs
@@ -0,0 +1,80 @@
+using System.Reflection;
+using Serilog;
+using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+using SquidStd.Plugin.Exceptions;
+
+namespace SquidStd.Plugin.Internal;
+
+///
+/// Scans a directory for external plugin assemblies, loads each into the default
+/// AssemblyLoadContext, and instantiates every concrete found.
+///
+internal static class PluginAssemblyScanner
+{
+ private static readonly ILogger Logger = Log.ForContext(typeof(PluginAssemblyScanner));
+
+ ///
+ /// Scans for *.dll files and returns every concrete
+ /// instantiated from them.
+ ///
+ /// The directory to scan for plugin assemblies.
+ ///
+ /// The directory does not exist, an assembly failed to load, or a plugin type failed to instantiate.
+ ///
+ public static IReadOnlyList Scan(string directory)
+ {
+ if (!Directory.Exists(directory))
+ {
+ throw new PluginLoadException($"Plugin directory '{directory}' does not exist.");
+ }
+
+ var plugins = new List();
+ var dllPaths = Directory.EnumerateFiles(directory, "*.dll")
+ .OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var dllPath in dllPaths)
+ {
+ List pluginTypes;
+
+ try
+ {
+ // Default AssemblyLoadContext: plugins are trusted and share type identity
+ // with the host (ISquidStdPlugin, DryIoc). No unload, no version isolation.
+ var assembly = Assembly.LoadFrom(dllPath);
+
+ pluginTypes = assembly.GetTypes()
+ .Where(type => typeof(ISquidStdPlugin).IsAssignableFrom(type) &&
+ type is { IsClass: true, IsAbstract: false })
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ throw new PluginLoadException($"Failed to load plugin assembly '{dllPath}'.", ex);
+ }
+
+ if (pluginTypes.Count == 0)
+ {
+ Logger.Debug("No plugins found in {AssemblyPath:l}", dllPath);
+
+ continue;
+ }
+
+ foreach (var pluginType in pluginTypes)
+ {
+ try
+ {
+ plugins.Add((ISquidStdPlugin)Activator.CreateInstance(pluginType)!);
+ }
+ catch (Exception ex)
+ {
+ throw new PluginLoadException(
+ $"Failed to instantiate plugin type '{pluginType.FullName}' from '{dllPath}'.",
+ ex
+ );
+ }
+ }
+ }
+
+ return plugins;
+ }
+}
diff --git a/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs b/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs
new file mode 100644
index 00000000..ac3a8aa0
--- /dev/null
+++ b/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs
@@ -0,0 +1,81 @@
+using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+using SquidStd.Plugin.Exceptions;
+
+namespace SquidStd.Plugin.Internal;
+
+///
+/// Orders plugins by dependency: a plugin always appears after every plugin it depends on.
+/// Plugin ids are compared case-insensitively. Fails fast on duplicate ids, missing
+/// dependencies, and dependency cycles.
+///
+internal static class PluginDependencyResolver
+{
+ public static IReadOnlyList Sort(IReadOnlyList plugins)
+ {
+ var byId = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var plugin in plugins)
+ {
+ if (!byId.TryAdd(plugin.Metadata.Id, plugin))
+ {
+ throw new PluginLoadException($"Duplicate plugin id '{plugin.Metadata.Id}'.");
+ }
+ }
+
+ var sorted = new List(plugins.Count);
+ var visiting = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var visited = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var path = new List();
+
+ foreach (var plugin in plugins)
+ {
+ Visit(plugin, byId, visiting, visited, path, sorted);
+ }
+
+ return sorted;
+ }
+
+ private static void Visit(
+ ISquidStdPlugin plugin,
+ Dictionary byId,
+ HashSet visiting,
+ HashSet visited,
+ List path,
+ List sorted
+ )
+ {
+ var id = plugin.Metadata.Id;
+
+ if (visited.Contains(id))
+ {
+ return;
+ }
+
+ if (!visiting.Add(id))
+ {
+ var start = path.FindIndex(p => string.Equals(p, id, StringComparison.OrdinalIgnoreCase));
+ var cycle = string.Join(" -> ", path.Skip(start).Append(id));
+
+ throw new PluginLoadException($"Plugin dependency cycle detected: {cycle}.");
+ }
+
+ path.Add(id);
+
+ foreach (var dependencyId in plugin.Metadata.Dependencies)
+ {
+ if (!byId.TryGetValue(dependencyId, out var dependency))
+ {
+ throw new PluginLoadException(
+ $"Plugin '{id}' depends on '{dependencyId}', which is not registered."
+ );
+ }
+
+ Visit(dependency, byId, visiting, visited, path, sorted);
+ }
+
+ path.RemoveAt(path.Count - 1);
+ visiting.Remove(id);
+ visited.Add(id);
+ sorted.Add(plugin);
+ }
+}
diff --git a/src/SquidStd.Plugin/PluginCollectionBuilder.cs b/src/SquidStd.Plugin/PluginCollectionBuilder.cs
new file mode 100644
index 00000000..324601cb
--- /dev/null
+++ b/src/SquidStd.Plugin/PluginCollectionBuilder.cs
@@ -0,0 +1,58 @@
+using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+
+namespace SquidStd.Plugin;
+
+///
+/// Collects internal plugin registrations and external plugin directories for the loader.
+/// Collection only: nothing is loaded or validated until the loader runs.
+///
+public sealed class PluginCollectionBuilder
+{
+ private readonly List _plugins = [];
+ private readonly List _directories = [];
+
+ internal IReadOnlyList Plugins => _plugins;
+
+ internal IReadOnlyList Directories => _directories;
+
+ ///
+ /// Registers an internal plugin by type; it is instantiated immediately via the public
+ /// parameterless constructor.
+ ///
+ /// The plugin type.
+ /// The same builder for chaining.
+ public PluginCollectionBuilder Add()
+ where TPlugin : ISquidStdPlugin, new()
+ {
+ _plugins.Add(new TPlugin());
+
+ return this;
+ }
+
+ ///
+ /// Registers an internal plugin instance.
+ ///
+ /// The plugin instance.
+ /// The same builder for chaining.
+ public PluginCollectionBuilder Add(ISquidStdPlugin plugin)
+ {
+ ArgumentNullException.ThrowIfNull(plugin);
+ _plugins.Add(plugin);
+
+ return this;
+ }
+
+ ///
+ /// Adds a directory to scan for external plugin assemblies (*.dll, non-recursive).
+ /// Relative paths are resolved against the bootstrap root directory.
+ ///
+ /// Absolute or root-relative directory path.
+ /// The same builder for chaining.
+ public PluginCollectionBuilder FromDirectory(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ _directories.Add(path);
+
+ return this;
+ }
+}
diff --git a/src/SquidStd.Plugin/README.md b/src/SquidStd.Plugin/README.md
new file mode 100644
index 00000000..ec54bac8
--- /dev/null
+++ b/src/SquidStd.Plugin/README.md
@@ -0,0 +1,64 @@
+SquidStd.Plugin
+
+Loader for SquidStd plugins. Wires internal and external `ISquidStdPlugin` implementations into the
+bootstrap: it collects them, resolves the dependency order across the whole set, and calls `Configure`
+on each one before the bootstrap starts.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Plugin
+```
+
+## Usage
+
+```csharp
+using SquidStd.Core.Data.Bootstrap;
+using SquidStd.Plugin.Extensions;
+using SquidStd.Services.Core.Services.Bootstrap;
+
+var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions
+{
+ ConfigName = "myapp",
+ RootDirectory = "./data"
+});
+
+bootstrap.UsePlugins(plugins =>
+{
+ plugins.Add(); // internal, by type
+ plugins.Add(new MetricsPlugin()); // internal, by instance
+ plugins.FromDirectory("plugins"); // external assemblies (*.dll)
+});
+
+await bootstrap.RunAsync();
+```
+
+## How loading works
+
+- Plugins are ordered by dependency: `PluginMetadata.Dependencies` (plugin ids, compared
+ case-insensitively) is resolved across internal and external plugins together, so an external plugin
+ can depend on an internal one and vice versa.
+- `Configure` runs before the configuration load, so plugins can register their own configuration
+ sections and services ahead of time.
+- Each plugin receives a `PluginContext` populated with the standard keys: `PluginContextKeys.RootDirectory`
+ (the bootstrap root directory) and `PluginContextKeys.AppName` (the bootstrap app name).
+
+## Trusted plugins
+
+External assemblies load into the default `AssemblyLoadContext`: there is no unloading and no
+per-plugin version isolation, so plugins are expected to be trusted. A failing plugin stops startup,
+either with a `PluginLoadException` (discovery, ordering, or instantiation failure) or with the
+plugin's own exception from `Configure`.
+
+Call `UsePlugins` before `ConfigureLogging()` or any start method: the bootstrap loads its
+configuration during startup, and config sections registered by plugins after that point are never
+loaded.
+
+## Related
+
+- Contracts: [SquidStd.Plugin.Abstractions](https://tgiachi.github.io/squid-std/articles/plugin-abstractions.html)
+- [SquidStd.Core](https://tgiachi.github.io/squid-std/articles/core.html)
+
+## License
+
+MIT - part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Plugin/SquidStd.Plugin.csproj b/src/SquidStd.Plugin/SquidStd.Plugin.csproj
new file mode 100644
index 00000000..84d93eb2
--- /dev/null
+++ b/src/SquidStd.Plugin/SquidStd.Plugin.csproj
@@ -0,0 +1,19 @@
+
+
+
+ true
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs
index 031f1c22..e082a909 100644
--- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs
+++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs
@@ -14,10 +14,10 @@
using SquidStd.Core.Interfaces.Events;
using SquidStd.Core.Interfaces.Lifecycle;
using SquidStd.Core.Types;
+using SquidStd.Core.Types.Bootstrap;
using SquidStd.Services.Core.Extensions;
using SquidStd.Services.Core.Extensions.Logger;
using SquidStd.Services.Core.Services.Lifecycle;
-using SquidStd.Services.Core.Types;
namespace SquidStd.Services.Core.Services.Bootstrap;
@@ -43,6 +43,9 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap
///
public IContainer Container { get; }
+ ///
+ public BootstrapStateType State => _state;
+
///
/// Initializes a bootstrapper with default options.
///
diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs
index 38d4380a..817fcfee 100644
--- a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs
+++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs
@@ -2,6 +2,7 @@
using SquidStd.Core.Data.Bootstrap;
using SquidStd.Core.Interfaces.Bootstrap;
using SquidStd.Core.Interfaces.Config;
+using SquidStd.Core.Types.Bootstrap;
namespace SquidStd.Tests.AspNetCore;
@@ -17,6 +18,8 @@ internal sealed class FakeSquidStdBootstrap : ISquidStdBootstrap
public IContainer Container { get; } = new Container();
+ public BootstrapStateType State { get; set; } = BootstrapStateType.Created;
+
public ISquidStdBootstrap ConfigureService(Func configure)
=> ConfigureServices(configure);
@@ -64,6 +67,7 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
StartCount++;
+ State = BootstrapStateType.Started;
return ValueTask.CompletedTask;
}
@@ -72,6 +76,7 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
StopCount++;
+ State = BootstrapStateType.Stopped;
return ValueTask.CompletedTask;
}
diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs
new file mode 100644
index 00000000..2a14fa3a
--- /dev/null
+++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs
@@ -0,0 +1,25 @@
+using SquidStd.Core.Data.Bootstrap;
+using SquidStd.Core.Types.Bootstrap;
+using SquidStd.Services.Core.Services.Bootstrap;
+using SquidStd.Tests.Support;
+
+namespace SquidStd.Tests.Bootstrap;
+
+public class SquidStdBootstrapStateTests
+{
+ [Fact]
+ public async Task State_TransitionsAcrossLifecycle()
+ {
+ using var temp = new TempDirectory();
+ await using var bootstrap =
+ SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "statetest", RootDirectory = temp.Path });
+
+ Assert.Equal(BootstrapStateType.Created, bootstrap.State);
+
+ await bootstrap.StartAsync();
+ Assert.Equal(BootstrapStateType.Started, bootstrap.State);
+
+ await bootstrap.StopAsync();
+ Assert.Equal(BootstrapStateType.Stopped, bootstrap.State);
+ }
+}
diff --git a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs
index 303a21a5..233e02a2 100644
--- a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs
+++ b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs
@@ -4,6 +4,7 @@
using SquidStd.Core.Data.Bootstrap;
using SquidStd.Core.Interfaces.Bootstrap;
using SquidStd.Core.Interfaces.Config;
+using SquidStd.Core.Types.Bootstrap;
using SquidStd.Services.Core.Services.Lifecycle;
namespace SquidStd.Tests.ConsoleCommands;
@@ -83,6 +84,8 @@ private sealed class StopSpyBootstrap : ISquidStdBootstrap
public IContainer Container { get; } = new Container();
+ public BootstrapStateType State { get; private set; } = BootstrapStateType.Created;
+
public StopSpyBootstrap(Action onStop)
{
_onStop = onStop;
@@ -111,10 +114,15 @@ public Task RunAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public ValueTask StartAsync(CancellationToken cancellationToken = default)
- => ValueTask.CompletedTask;
+ {
+ State = BootstrapStateType.Started;
+
+ return ValueTask.CompletedTask;
+ }
public ValueTask StopAsync(CancellationToken cancellationToken = default)
{
+ State = BootstrapStateType.Stopped;
_onStop();
return ValueTask.CompletedTask;
diff --git a/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs b/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs
new file mode 100644
index 00000000..d017450e
--- /dev/null
+++ b/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs
@@ -0,0 +1,74 @@
+using SquidStd.Plugin.Exceptions;
+using SquidStd.Plugin.Internal;
+using SquidStd.Tests.Plugin.Support;
+
+namespace SquidStd.Tests.Plugin;
+
+public class PluginAssemblyScannerTests
+{
+ [Fact]
+ public void Scan_MissingDirectory_Throws()
+ {
+ var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+
+ var ex = Assert.Throws(() => PluginAssemblyScanner.Scan(missing));
+
+ Assert.Contains(missing, ex.Message);
+ }
+
+ [Fact]
+ public void Scan_DirectoryWithPluginAssembly_LoadsAndInstantiates()
+ {
+ var directory = Directory.CreateTempSubdirectory();
+
+ try
+ {
+ PluginAssemblyFactory.CompilePluginAssembly(directory.FullName, "tests.generated");
+
+ var plugins = PluginAssemblyScanner.Scan(directory.FullName);
+
+ Assert.Single(plugins);
+ Assert.Equal("tests.generated", plugins[0].Metadata.Id);
+ }
+ finally
+ {
+ directory.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void Scan_AssemblyWithoutPlugins_IsSkipped()
+ {
+ var directory = Directory.CreateTempSubdirectory();
+
+ try
+ {
+ PluginAssemblyFactory.CompileNonPluginAssembly(directory.FullName);
+
+ var plugins = PluginAssemblyScanner.Scan(directory.FullName);
+
+ Assert.Empty(plugins);
+ }
+ finally
+ {
+ directory.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void Scan_CorruptDll_Throws()
+ {
+ var directory = Directory.CreateTempSubdirectory();
+
+ try
+ {
+ File.WriteAllBytes(Path.Combine(directory.FullName, "broken.dll"), [1, 2, 3, 4]);
+
+ Assert.Throws(() => PluginAssemblyScanner.Scan(directory.FullName));
+ }
+ finally
+ {
+ directory.Delete(true);
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs b/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs
new file mode 100644
index 00000000..4fb3fbd6
--- /dev/null
+++ b/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs
@@ -0,0 +1,52 @@
+using DryIoc;
+using SquidStd.Plugin;
+using SquidStd.Plugin.Abstractions.Data;
+using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+
+namespace SquidStd.Tests.Plugin;
+
+public class PluginCollectionBuilderTests
+{
+ private sealed class AlphaPlugin : ISquidStdPlugin
+ {
+ public PluginMetadata Metadata { get; } = new()
+ {
+ Id = "tests.alpha",
+ Name = "Alpha",
+ Version = new(1, 0, 0),
+ Author = "Tests"
+ };
+
+ public void Configure(IContainer container, PluginContext context) { }
+ }
+
+ [Fact]
+ public void Add_ByTypeAndInstance_CollectsInOrder()
+ {
+ var builder = new PluginCollectionBuilder();
+ var instance = new AlphaPlugin();
+
+ builder.Add().Add(instance);
+
+ Assert.Equal(2, builder.Plugins.Count);
+ Assert.Same(instance, builder.Plugins[1]);
+ }
+
+ [Fact]
+ public void Add_NullInstance_Throws()
+ => Assert.Throws(() => new PluginCollectionBuilder().Add(null!));
+
+ [Fact]
+ public void FromDirectory_CollectsPaths()
+ {
+ var builder = new PluginCollectionBuilder();
+
+ builder.FromDirectory("plugins").FromDirectory("extra");
+
+ Assert.Equal(["plugins", "extra"], builder.Directories);
+ }
+
+ [Fact]
+ public void FromDirectory_BlankPath_Throws()
+ => Assert.Throws(() => new PluginCollectionBuilder().FromDirectory(" "));
+}
diff --git a/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs b/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs
new file mode 100644
index 00000000..bb15fd12
--- /dev/null
+++ b/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs
@@ -0,0 +1,80 @@
+using SquidStd.Plugin.Exceptions;
+using SquidStd.Plugin.Internal;
+using SquidStd.Tests.Plugin.Support;
+
+namespace SquidStd.Tests.Plugin;
+
+public class PluginDependencyResolverTests
+{
+ private static string[] Ids(IEnumerable plugins)
+ => plugins.Select(p => p.Metadata.Id).ToArray();
+
+ [Fact]
+ public void Sort_NoDependencies_KeepsRegistrationOrder()
+ {
+ var sorted = PluginDependencyResolver.Sort([new StubPlugin("c"), new StubPlugin("a"), new StubPlugin("b")]);
+
+ Assert.Equal(["c", "a", "b"], Ids(sorted));
+ }
+
+ [Fact]
+ public void Sort_DependencyComesFirst()
+ {
+ var sorted = PluginDependencyResolver.Sort([new StubPlugin("app", "core"), new StubPlugin("core")]);
+
+ Assert.Equal(["core", "app"], Ids(sorted));
+ }
+
+ [Fact]
+ public void Sort_TransitiveChain_IsOrdered()
+ {
+ var sorted = PluginDependencyResolver.Sort(
+ [new StubPlugin("web", "app"), new StubPlugin("app", "core"), new StubPlugin("core")]
+ );
+
+ Assert.Equal(["core", "app", "web"], Ids(sorted));
+ }
+
+ [Fact]
+ public void Sort_DependencyIdsAreCaseInsensitive()
+ {
+ var sorted = PluginDependencyResolver.Sort([new StubPlugin("App", "CORE"), new StubPlugin("core")]);
+
+ Assert.Equal(["core", "App"], Ids(sorted));
+ }
+
+ [Fact]
+ public void Sort_DuplicateId_Throws()
+ {
+ var ex = Assert.Throws(
+ () => PluginDependencyResolver.Sort([new StubPlugin("dup"), new StubPlugin("DUP")])
+ );
+
+ Assert.Contains("dup", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Sort_MissingDependency_ThrowsNamingBoth()
+ {
+ var ex = Assert.Throws(
+ () => PluginDependencyResolver.Sort([new StubPlugin("app", "ghost")])
+ );
+
+ Assert.Contains("app", ex.Message);
+ Assert.Contains("ghost", ex.Message);
+ }
+
+ [Fact]
+ public void Sort_Cycle_Throws()
+ {
+ var ex = Assert.Throws(
+ () => PluginDependencyResolver.Sort([new StubPlugin("a", "b"), new StubPlugin("b", "a")])
+ );
+
+ Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Sort_SelfDependency_Throws()
+ => Assert.Throws(() => PluginDependencyResolver.Sort([new StubPlugin("a", "a")]));
+}
diff --git a/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs b/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs
new file mode 100644
index 00000000..f9e159f9
--- /dev/null
+++ b/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs
@@ -0,0 +1,130 @@
+using DryIoc;
+using SquidStd.Core.Data.Bootstrap;
+using SquidStd.Plugin.Abstractions.Data;
+using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+using SquidStd.Plugin.Extensions;
+using SquidStd.Services.Core.Services.Bootstrap;
+using SquidStd.Tests.Plugin.Support;
+using SquidStd.Tests.Support;
+
+namespace SquidStd.Tests.Plugin;
+
+public class SquidStdBootstrapPluginExtensionsTests
+{
+ private sealed class RecorderPlugin : ISquidStdPlugin
+ {
+ private readonly List _order;
+
+ public PluginMetadata Metadata { get; }
+
+ public IContainer? SeenContainer { get; private set; }
+
+ public PluginContext? SeenContext { get; private set; }
+
+ public RecorderPlugin(string id, List order, params string[] dependencies)
+ {
+ _order = order;
+ Metadata = new()
+ {
+ Id = id,
+ Name = id,
+ Version = new(1, 0, 0),
+ Author = "Tests",
+ Dependencies = dependencies
+ };
+ }
+
+ public void Configure(IContainer container, PluginContext context)
+ {
+ SeenContainer = container;
+ SeenContext = context;
+ _order.Add(Metadata.Id);
+ }
+ }
+
+ private sealed class ThrowingPlugin : ISquidStdPlugin
+ {
+ public PluginMetadata Metadata { get; } = new()
+ {
+ Id = "tests.throwing",
+ Name = "Throwing",
+ Version = new(1, 0, 0),
+ Author = "Tests"
+ };
+
+ public void Configure(IContainer container, PluginContext context)
+ => throw new InvalidOperationException("boom");
+ }
+
+ [Fact]
+ public async Task UsePlugins_ConfiguresInDependencyOrder_WithBootstrapContainerAndContext()
+ {
+ using var root = new TempDirectory();
+
+ await using var bootstrap = SquidStdBootstrap.Create(
+ new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path, AppName = "PluginHost" }
+ );
+
+ var order = new List();
+ var app = new RecorderPlugin("tests.app", order, "tests.core");
+ var core = new RecorderPlugin("tests.core", order);
+
+ bootstrap.UsePlugins(plugins => plugins.Add(app).Add(core));
+
+ Assert.Equal(["tests.core", "tests.app"], order);
+ Assert.Same(bootstrap.Container, app.SeenContainer);
+ Assert.Equal(root.Path, core.SeenContext!.GetData(PluginContextKeys.RootDirectory));
+ Assert.Equal("PluginHost", core.SeenContext!.GetData(PluginContextKeys.AppName));
+ }
+
+ [Fact]
+ public async Task UsePlugins_LoadsExternalPluginsFromRelativeDirectory()
+ {
+ using var root = new TempDirectory();
+ var pluginsDir = Directory.CreateDirectory(root.Combine("plugins"));
+ PluginAssemblyFactory.CompilePluginAssembly(pluginsDir.FullName, "tests.external");
+
+ await using var bootstrap = SquidStdBootstrap.Create(
+ new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path }
+ );
+
+ bootstrap.UsePlugins(plugins => plugins.FromDirectory("plugins"));
+
+ Assert.Equal("tests.external", bootstrap.Container.Resolve(serviceKey: "plugin-marker"));
+ }
+
+ [Fact]
+ public async Task UsePlugins_AfterStart_Throws()
+ {
+ using var root = new TempDirectory();
+
+ await using var bootstrap = SquidStdBootstrap.Create(
+ new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path }
+ );
+
+ await bootstrap.StartAsync();
+
+ try
+ {
+ Assert.Throws(() => bootstrap.UsePlugins(_ => { }));
+ }
+ finally
+ {
+ await bootstrap.StopAsync();
+ }
+ }
+
+ [Fact]
+ public async Task UsePlugins_ConfigureException_Propagates()
+ {
+ using var root = new TempDirectory();
+
+ await using var bootstrap = SquidStdBootstrap.Create(
+ new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path }
+ );
+
+ Assert.Throws(
+ () => bootstrap.UsePlugins(plugins => plugins.Add(new ThrowingPlugin()))
+ );
+ }
+}
diff --git a/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs b/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs
new file mode 100644
index 00000000..ee3444d4
--- /dev/null
+++ b/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs
@@ -0,0 +1,68 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace SquidStd.Tests.Plugin.Support;
+
+internal static class PluginAssemblyFactory
+{
+ public static string CompilePluginAssembly(string directory, string pluginId)
+ {
+ var source = $$"""
+ using DryIoc;
+ using SquidStd.Plugin.Abstractions.Data;
+ using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+
+ public sealed class GeneratedPlugin : ISquidStdPlugin
+ {
+ public PluginMetadata Metadata { get; } = new()
+ {
+ Id = "{{pluginId}}",
+ Name = "Generated Plugin",
+ Version = new(1, 0, 0),
+ Author = "Tests"
+ };
+
+ public void Configure(IContainer container, PluginContext context)
+ => container.RegisterInstance("{{pluginId}}", serviceKey: "plugin-marker");
+ }
+ """;
+
+ return Emit(directory, "generated-plugin.dll", source);
+ }
+
+ public static string CompileNonPluginAssembly(string directory)
+ => Emit(directory, "not-a-plugin.dll", "public sealed class NotAPlugin { }");
+
+ private static string Emit(string directory, string fileName, string source)
+ {
+ var references = AppDomain.CurrentDomain.GetAssemblies()
+ // Earlier tests in this run may have loaded plugin assemblies from temp
+ // directories that have since been deleted (Scan uses Assembly.LoadFrom into
+ // the default ALC, which never unloads). Skip those so a stale Location doesn't
+ // blow up MetadataReference.CreateFromFile for unrelated tests.
+ .Where(assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location) &&
+ File.Exists(assembly.Location))
+ .Select(assembly => MetadataReference.CreateFromFile(assembly.Location))
+ .Cast()
+ .ToList();
+
+ var compilation = CSharpCompilation.Create(
+ $"GeneratedPluginAssembly_{Guid.NewGuid():N}",
+ [CSharpSyntaxTree.ParseText(source)],
+ references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+
+ var path = Path.Combine(directory, fileName);
+ var result = compilation.Emit(path);
+
+ if (!result.Success)
+ {
+ throw new InvalidOperationException(
+ string.Join(Environment.NewLine, result.Diagnostics.Select(d => d.ToString()))
+ );
+ }
+
+ return path;
+ }
+}
diff --git a/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs b/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs
new file mode 100644
index 00000000..abaa5633
--- /dev/null
+++ b/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs
@@ -0,0 +1,24 @@
+using DryIoc;
+using SquidStd.Plugin.Abstractions.Data;
+using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
+
+namespace SquidStd.Tests.Plugin.Support;
+
+internal sealed class StubPlugin : ISquidStdPlugin
+{
+ public PluginMetadata Metadata { get; }
+
+ public StubPlugin(string id, params string[] dependencies)
+ {
+ Metadata = new()
+ {
+ Id = id,
+ Name = id,
+ Version = new(1, 0, 0),
+ Author = "Tests",
+ Dependencies = dependencies
+ };
+ }
+
+ public void Configure(IContainer container, PluginContext context) { }
+}
diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj
index e926d6f9..657604cc 100644
--- a/tests/SquidStd.Tests/SquidStd.Tests.csproj
+++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj
@@ -43,6 +43,7 @@
+