using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; namespace StellaOps.Plugin.Hosting; public static class PluginHost { private static readonly object Sync = new(); private static readonly Dictionary LoadedPlugins = new(StringComparer.OrdinalIgnoreCase); public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null) { if (options == null) { throw new ArgumentNullException(nameof(options)); } var baseDirectory = options.ResolveBaseDirectory(); var pluginDirectory = ResolvePluginDirectory(options, baseDirectory); if (options.EnsureDirectoryExists && !Directory.Exists(pluginDirectory)) { Directory.CreateDirectory(pluginDirectory); } if (!Directory.Exists(pluginDirectory)) { logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory); return new PluginHostResult(pluginDirectory, Array.Empty(), Array.Empty(), Array.Empty()); } var searchPatterns = BuildSearchPatterns(options, pluginDirectory); var discovered = DiscoverPluginFiles(pluginDirectory, searchPatterns, options.RecursiveSearch, logger); var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames); var loaded = new List(orderedFiles.Count); lock (Sync) { foreach (var file in orderedFiles) { if (LoadedPlugins.TryGetValue(file, out var existing)) { loaded.Add(existing); continue; } try { var loadContext = new PluginLoadContext(file); var assembly = loadContext.LoadFromAssemblyPath(file); var descriptor = new PluginAssembly(file, assembly, loadContext); LoadedPlugins[file] = descriptor; loaded.Add(descriptor); logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file); } catch (Exception ex) { logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file); } } } var missingOrdered = new ReadOnlyCollection(missingOrderedNames); return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection(loaded), missingOrdered); } private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory) { if (string.IsNullOrWhiteSpace(options.PluginsDirectory)) { var defaultDirectory = !string.IsNullOrWhiteSpace(options.PrimaryPrefix) ? $"{options.PrimaryPrefix}.PluginBinaries" : "PluginBinaries"; return Path.Combine(baseDirectory, defaultDirectory); } if (Path.IsPathRooted(options.PluginsDirectory)) { return options.PluginsDirectory; } return Path.Combine(baseDirectory, options.PluginsDirectory); } private static IReadOnlyList BuildSearchPatterns(PluginHostOptions options, string pluginDirectory) { var patterns = new List(); if (options.SearchPatterns.Count > 0) { patterns.AddRange(options.SearchPatterns); } else { var prefixes = new List(); if (!string.IsNullOrWhiteSpace(options.PrimaryPrefix)) { prefixes.Add(options.PrimaryPrefix); } else if (System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name is { } entryName) { prefixes.Add(entryName); } prefixes.AddRange(options.AdditionalPrefixes); if (prefixes.Count == 0) { // Fallback to directory name prefixes.Add(Path.GetFileName(pluginDirectory)); } foreach (var prefix in prefixes.Where(p => !string.IsNullOrWhiteSpace(p))) { patterns.Add($"{prefix}.Plugin.*.dll"); } } return new ReadOnlyCollection(patterns.Distinct(StringComparer.OrdinalIgnoreCase).ToList()); } private static List DiscoverPluginFiles( string pluginDirectory, IReadOnlyList searchPatterns, bool recurse, ILogger? logger) { var files = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; foreach (var pattern in searchPatterns) { try { foreach (var file in Directory.EnumerateFiles(pluginDirectory, pattern, searchOption)) { if (IsHiddenPath(file)) { continue; } if (seen.Add(file)) { files.Add(file); } } } catch (DirectoryNotFoundException) { // Directory could be removed between the existence check and enumeration. logger?.LogDebug("Plugin directory '{PluginDirectory}' disappeared before enumeration.", pluginDirectory); } } return files; } private static List ApplyExplicitOrdering( List discoveredFiles, IList pluginOrder, out List missingNames) { if (pluginOrder.Count == 0 || discoveredFiles.Count == 0) { missingNames = new List(); discoveredFiles.Sort(StringComparer.OrdinalIgnoreCase); return discoveredFiles; } var configuredSet = new HashSet(pluginOrder, StringComparer.OrdinalIgnoreCase); var fileLookup = discoveredFiles.ToDictionary( k => Path.GetFileNameWithoutExtension(k), StringComparer.OrdinalIgnoreCase); var specified = new List(); foreach (var name in pluginOrder) { if (fileLookup.TryGetValue(name, out var file)) { specified.Add(file); } } var unspecified = discoveredFiles .Where(f => !configuredSet.Contains(Path.GetFileNameWithoutExtension(f))) .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) .ToList(); missingNames = pluginOrder .Where(name => !fileLookup.ContainsKey(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); specified.AddRange(unspecified); return specified; } private static bool IsHiddenPath(string filePath) { var directory = Path.GetDirectoryName(filePath); while (!string.IsNullOrEmpty(directory)) { var name = Path.GetFileName(directory); if (name.StartsWith(".", StringComparison.Ordinal)) { return true; } directory = Path.GetDirectoryName(directory); } return false; } }