From 8ed06d2dba620b244cb1a56cd5b2a610deaf0d41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:30:36 +0000 Subject: [PATCH 1/2] Initial plan From 2348f6594df4466b86f3a787c4f57a2fd2eb8455 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:40:08 +0000 Subject: [PATCH 2/2] Add ExtensionLoader DI integration (IExtensionLoader, ExtensionLoader, tests, registration) Co-authored-by: michaelbeale-IL <63321611+michaelbeale-IL@users.noreply.github.com> --- .../ExtensionLoadingIntegrationTests.cs | 127 +++++++++++++ src/Libraries/ACATCore/ACAT.Core.csproj | 2 + .../ServiceCollectionExtensions.cs | 20 +++ .../Utility/TypeLoader/ExtensionLoader.cs | 167 ++++++++++++++++++ .../Utility/TypeLoader/IExtensionLoader.cs | 82 +++++++++ 5 files changed, 398 insertions(+) create mode 100644 src/Libraries/ACATCore/Utility/TypeLoader/ExtensionLoader.cs create mode 100644 src/Libraries/ACATCore/Utility/TypeLoader/IExtensionLoader.cs diff --git a/src/Libraries/ACATCore.Tests.Configuration/ExtensionLoadingIntegrationTests.cs b/src/Libraries/ACATCore.Tests.Configuration/ExtensionLoadingIntegrationTests.cs index 8b7c582c..891c417f 100644 --- a/src/Libraries/ACATCore.Tests.Configuration/ExtensionLoadingIntegrationTests.cs +++ b/src/Libraries/ACATCore.Tests.Configuration/ExtensionLoadingIntegrationTests.cs @@ -12,7 +12,9 @@ using ACAT.Core.Extensions; using ACAT.Core.PanelManagement; +using ACAT.Core.DependencyInjection; using ACAT.Core.Utility; +using ACAT.Core.Utility.TypeLoader; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -43,6 +45,16 @@ public void Cleanup() Context.ServiceProvider = null; } + // --------------------------------------------------------------- + // Minimal fake extension used only in ExtensionLoader tests + // --------------------------------------------------------------- + + private interface IFakeExtension : IPluginExtension { } + + // --------------------------------------------------------------- + // Existing ExtensionInstantiator tests (unchanged) + // --------------------------------------------------------------- + [TestMethod] public void ExtensionInstantiator_WithServiceProvider_CanCreateExtensions() { @@ -144,5 +156,120 @@ public void ServiceProvider_CanCreateLoggers() // Assert Assert.IsNotNull(logger); } + + // --------------------------------------------------------------- + // ExtensionLoader tests + // --------------------------------------------------------------- + + [TestMethod] + public void ExtensionLoader_Constructor_WithNullServiceProvider_ThrowsArgumentNullException() + { + // Act & Assert + Assert.ThrowsException( + () => new ExtensionLoader(null)); + } + + [TestMethod] + public void ExtensionLoader_Constructor_WithServiceProvider_Succeeds() + { + // Act + var loader = new ExtensionLoader(_serviceProvider); + + // Assert + Assert.IsNotNull(loader); + } + + [TestMethod] + public void ExtensionLoader_LoadedTypes_IsEmptyBeforeLoadingAssemblies() + { + // Arrange + var loader = new ExtensionLoader(_serviceProvider); + + // Assert + Assert.IsNotNull(loader.LoadedTypes); + Assert.AreEqual(0, loader.LoadedTypes.Count); + } + + [TestMethod] + public void ExtensionLoader_CreateInstance_WithUnknownGuid_ReturnsNull() + { + // Arrange + var loader = new ExtensionLoader(_serviceProvider); + var unknownId = Guid.NewGuid(); + + // Act + var instance = loader.CreateInstance(unknownId); + + // Assert + Assert.IsNull(instance); + } + + [TestMethod] + public void ExtensionLoader_CreateAllInstances_WithNoTypesLoaded_ReturnsEmptyCollection() + { + // Arrange + var loader = new ExtensionLoader(_serviceProvider); + + // Act + var instances = loader.CreateAllInstances(); + + // Assert + Assert.IsNotNull(instances); + Assert.IsFalse(instances.Any()); + } + + [TestMethod] + public void ExtensionLoader_RegisterExtensions_WithNullServices_ThrowsArgumentNullException() + { + // Arrange + var loader = new ExtensionLoader(_serviceProvider); + + // Act & Assert + Assert.ThrowsException( + () => loader.RegisterExtensions(null)); + } + + [TestMethod] + public void ExtensionLoader_RegisterExtensions_WithNoLoadedTypes_LeavesServicesUnchanged() + { + // Arrange + var loader = new ExtensionLoader(_serviceProvider); + var services = new ServiceCollection(); + var initialCount = services.Count; + + // Act + loader.RegisterExtensions(services); + + // Assert – no new registrations because no types were loaded + Assert.AreEqual(initialCount, services.Count); + } + + [TestMethod] + public void ExtensionLoader_AddExtensionLoader_RegistersIExtensionLoaderAsSingleton() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddExtensionLoader(); + var provider = services.BuildServiceProvider(); + + // Act + var loader1 = provider.GetService>(); + var loader2 = provider.GetService>(); + + // Assert – resolved as singleton (same reference both times) + Assert.IsNotNull(loader1); + Assert.AreSame(loader1, loader2); + } + + [TestMethod] + public void ExtensionLoader_ImplementsIExtensionLoader() + { + // Act + var loader = new ExtensionLoader(_serviceProvider); + + // Assert + Assert.IsInstanceOfType(loader, typeof(IExtensionLoader)); + } } } diff --git a/src/Libraries/ACATCore/ACAT.Core.csproj b/src/Libraries/ACATCore/ACAT.Core.csproj index c5aa8c20..d9363c56 100644 --- a/src/Libraries/ACATCore/ACAT.Core.csproj +++ b/src/Libraries/ACATCore/ACAT.Core.csproj @@ -394,6 +394,8 @@ + + diff --git a/src/Libraries/ACATCore/DependencyInjection/ServiceCollectionExtensions.cs b/src/Libraries/ACATCore/DependencyInjection/ServiceCollectionExtensions.cs index 11395cdd..cb50a7e7 100644 --- a/src/Libraries/ACATCore/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Libraries/ACATCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ using ACAT.Core.WidgetManagement; using ACAT.Core.TTSManagement; using ACAT.Core.Utility; +using ACAT.Core.Utility.TypeLoader; using ACAT.Core.WordPredictorManagement; using Microsoft.Extensions.DependencyInjection; using System; @@ -301,6 +302,25 @@ public static IServiceCollection AddWidgetManagement(this IServiceCollection ser return services; } + /// + /// Registers as a singleton + /// for the specified extension type. + /// + /// + /// The plugin-extension interface type (must implement ). + /// + /// The service collection to configure. + /// The service collection, for chaining. + public static IServiceCollection AddExtensionLoader(this IServiceCollection services) + where TExtension : class, IPluginExtension + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddSingleton>( + provider => new ExtensionLoader(provider)); + return services; + } + /// /// Registers ACAT configuration services including JSON schema validation, /// hot-reload support, and environment-specific configuration. diff --git a/src/Libraries/ACATCore/Utility/TypeLoader/ExtensionLoader.cs b/src/Libraries/ACATCore/Utility/TypeLoader/ExtensionLoader.cs new file mode 100644 index 00000000..7066543f --- /dev/null +++ b/src/Libraries/ACATCore/Utility/TypeLoader/ExtensionLoader.cs @@ -0,0 +1,167 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2013-2019; 2023 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 +// +// +// ExtensionLoader.cs +// +// Loads plugin extensions from assemblies and creates instances through the +// DI container. Wraps TypeLoader for type discovery and uses +// ActivatorUtilities so that extension constructors receive any registered +// services automatically. +// +//////////////////////////////////////////////////////////////////////////// + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; + +namespace ACAT.Core.Utility.TypeLoader +{ + /// + /// Loads plugin extensions from assemblies and instantiates them via + /// dependency injection. + /// + /// + /// + /// ties together the two existing + /// primitives: + /// + /// – scans assemblies and builds a + /// GUID → cache. + /// – constructs objects whose constructors + /// may declare services registered in the DI container. + /// + /// + /// + /// Typical usage: + /// + /// var loader = new ExtensionLoader<IMyExtension>(serviceProvider); + /// loader.LoadFromAssembly(path); + /// var instances = loader.CreateAllInstances(); + /// + /// + /// + /// To support a scenario where extensions themselves need to be resolved through + /// DI later (e.g., injected into other services), call + /// with the application's + /// before building the container. + /// + /// + /// + /// The plugin-extension interface type. Must be a reference type that implements + /// . + /// + public class ExtensionLoader : IExtensionLoader + where TExtension : class, IPluginExtension + { + private readonly IServiceProvider _serviceProvider; + private readonly TypeLoader _typeLoader; + private readonly ILogger> _logger; + + /// + /// Initialises a new that uses + /// to resolve extension dependencies. + /// + /// + /// The DI service provider used when creating extension instances. + /// + /// + /// Thrown when is . + /// + public ExtensionLoader(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _typeLoader = new TypeLoader(); + _logger = serviceProvider.GetService>>(); + } + + /// + public IReadOnlyDictionary LoadedTypes => _typeLoader.LoadedTypes; + + /// + public void LoadFromAssembly(string assemblyPath, bool firstOrDefault = true) + { + _typeLoader.LoadFromAssembly(assemblyPath, firstOrDefault); + } + + /// + public void LoadFromAssemblies(IEnumerable assemblyPaths) + { + _typeLoader.LoadFromAssemblies(assemblyPaths); + } + + /// + public TExtension CreateInstance(Guid id) + { + if (!_typeLoader.LoadedTypes.TryGetValue(id, out var type)) + { + _logger?.LogWarning("No extension type found for ID {ExtensionId}", id); + return null; + } + + try + { + _logger?.LogDebug("Creating extension instance for {TypeName}", type.FullName); + var instance = ActivatorUtilities.CreateInstance(_serviceProvider, type); + + if (instance is TExtension extension) + { + _logger?.LogInformation("Successfully created extension: {ExtensionName}", type.Name); + return extension; + } + + _logger?.LogWarning("Type {TypeName} does not implement {Interface}", + type.FullName, typeof(TExtension).FullName); + return null; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to create extension instance for {TypeName}", type.FullName); + return null; + } + } + + /// + public IEnumerable CreateAllInstances() + { + var instances = new List(); + + foreach (var kvp in _typeLoader.LoadedTypes) + { + var instance = CreateInstance(kvp.Key); + if (instance != null) + { + instances.Add(instance); + } + } + + return instances; + } + + /// + public void RegisterExtensions(IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + + foreach (var kvp in _typeLoader.LoadedTypes) + { + var extensionType = kvp.Value; + + // Register the concrete type under itself + services.Add(new ServiceDescriptor(extensionType, extensionType, lifetime)); + + // Also register under the TExtension interface so callers can resolve + // IEnumerable or request TExtension directly + services.Add(new ServiceDescriptor(typeof(TExtension), extensionType, lifetime)); + + _logger?.LogDebug( + "Registered extension {TypeName} with lifetime {Lifetime}", + extensionType.FullName, lifetime); + } + } + } +} diff --git a/src/Libraries/ACATCore/Utility/TypeLoader/IExtensionLoader.cs b/src/Libraries/ACATCore/Utility/TypeLoader/IExtensionLoader.cs new file mode 100644 index 00000000..7410279b --- /dev/null +++ b/src/Libraries/ACATCore/Utility/TypeLoader/IExtensionLoader.cs @@ -0,0 +1,82 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2013-2019; 2023 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 +// +// +// IExtensionLoader.cs +// +// Interface for loading and instantiating plugin extensions using dependency +// injection. Combines assembly scanning (via TypeLoader) with DI-aware +// object creation and optional service-container registration. +// +//////////////////////////////////////////////////////////////////////////// + +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; + +namespace ACAT.Core.Utility.TypeLoader +{ + /// + /// Defines the contract for loading plugin extensions with dependency-injection support. + /// + /// + /// The plugin-extension interface type. Must be a reference type that implements + /// . + /// + public interface IExtensionLoader + where TExtension : class, IPluginExtension + { + /// + /// Gets a read-only view of all extension types that have been discovered so far, + /// keyed by their plugin GUID. + /// + IReadOnlyDictionary LoadedTypes { get; } + + /// + /// Scans a single assembly for types that implement + /// and adds them to . + /// + /// Full path to the assembly file to scan. + /// + /// When (the default) only the first matching type is added. + /// When every matching type is added. + /// + void LoadFromAssembly(string assemblyPath, bool firstOrDefault = true); + + /// + /// Scans multiple assemblies for types that implement . + /// + /// Paths to the assemblies to scan. + void LoadFromAssemblies(IEnumerable assemblyPaths); + + /// + /// Creates a DI-resolved instance of the extension identified by . + /// + /// The GUID of the extension to instantiate. + /// + /// The newly created extension, or if the GUID is not found + /// in or instantiation fails. + /// + TExtension CreateInstance(Guid id); + + /// + /// Creates DI-resolved instances for every type in . + /// Types that fail to instantiate are skipped (errors are logged). + /// + /// An enumerable of successfully created extension instances. + IEnumerable CreateAllInstances(); + + /// + /// Registers all types currently in with the supplied + /// service collection so that they can be resolved through the DI container. + /// + /// The service collection to populate. + /// + /// The service lifetime to use for the registered types. + /// Defaults to . + /// + void RegisterExtensions(IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient); + } +}