diff --git a/PlexCleaner/CommandLineOptions.cs b/PlexCleaner/CommandLineOptions.cs index c55ad54a..5dc9335c 100644 --- a/PlexCleaner/CommandLineOptions.cs +++ b/PlexCleaner/CommandLineOptions.cs @@ -19,7 +19,7 @@ public class CommandLineOptions public string ResultsFile { get; set; } = string.Empty; public string SchemaFile { get; set; } = string.Empty; public string SettingsFile { get; set; } = string.Empty; - public string PluginAssembly { get; set; } = string.Empty; + public FileInfo? PluginAssembly { get; set; } } public class CommandLineParser @@ -131,15 +131,25 @@ private class CommandHandler(Func action) : SynchronousCommand HelpName = "boolean", }; - private readonly Option _pluginAssemblyOption = new("--pluginassembly") + // FileInfo with AcceptExistingOnly validates the path exists at parse time, so the plugin loader + // does not need to re-check it. Both Required and the existence check are gated to non-AOT builds so + // that under AOT the custom command still reaches CustomCommand and emits the non-AOT error. +#if PLUGINS + private readonly Option _pluginAssemblyOption = new Option( + "--pluginassembly" + ) { Description = "Path to a plugin assembly implementing IProcessPlugin", HelpName = "filepath", - // Not required in AOT builds so custom can reach CustomCommand and emit the non-AOT error -#if PLUGINS Required = true, -#endif + }.AcceptExistingOnly(); +#else + private readonly Option _pluginAssemblyOption = new("--pluginassembly") + { + Description = "Path to a plugin assembly implementing IProcessPlugin", + HelpName = "filepath", }; +#endif private RootCommand CreateRootCommand() { @@ -440,7 +450,7 @@ public CommandLineOptions Bind() ResultsFile = Result.GetValue(_resultsFileOption) ?? string.Empty, SchemaFile = Result.GetValue(_schemaFileOption) ?? string.Empty, SettingsFile = Result.GetValue(_settingsFileOption) ?? string.Empty, - PluginAssembly = Result.GetValue(_pluginAssemblyOption) ?? string.Empty, + PluginAssembly = Result.GetValue(_pluginAssemblyOption), }; return options; diff --git a/PlexCleaner/PluginLoader.cs b/PlexCleaner/PluginLoader.cs index 6cc3f66f..2544ee00 100644 --- a/PlexCleaner/PluginLoader.cs +++ b/PlexCleaner/PluginLoader.cs @@ -59,28 +59,18 @@ public static class PluginLoader "Loads a plugin assembly and discovers IProcessPlugin via reflection" )] [RequiresDynamicCode("Loads a plugin assembly at runtime")] - public static IProcessPlugin? Load(string assemblyPath) + public static IProcessPlugin? Load(FileInfo assemblyFile) { - // An empty path would resolve to the current directory and report a misleading error - if (string.IsNullOrWhiteSpace(assemblyPath)) - { - Log.Error("Plugin assembly path is empty"); - return null; - } + ArgumentNullException.ThrowIfNull(assemblyFile); - // Resolve inside the try so a malformed path (GetFullPath throws) fails cleanly with a log - string fullPath = assemblyPath; + // Start with the safe file name and upgrade to the resolved full path inside the try, so the + // catch can log the most specific location available without FullName throwing before the try + string assemblyPath = assemblyFile.Name; try { - fullPath = Path.GetFullPath(assemblyPath); - if (!File.Exists(fullPath)) - { - Log.Error("Plugin assembly not found : {AssemblyPath}", fullPath); - return null; - } - - PluginLoadContext context = new(fullPath); - Assembly assembly = context.LoadFromAssemblyPath(fullPath); + assemblyPath = assemblyFile.FullName; + PluginLoadContext context = new(assemblyPath); + Assembly assembly = context.LoadFromAssemblyPath(assemblyPath); // Require exactly one concrete IProcessPlugin implementation List pluginTypes = @@ -97,7 +87,7 @@ .. assembly Log.Error( "Plugin assembly must contain exactly one IProcessPlugin implementation, found {Count} : {AssemblyPath}", pluginTypes.Count, - fullPath + assemblyPath ); return null; } @@ -115,13 +105,14 @@ .. assembly return null; } - Log.Information("Loaded plugin : {Name} : {AssemblyPath}", plugin.Name, fullPath); + Log.Information("Loaded plugin : {Name} : {AssemblyPath}", plugin.Name, assemblyPath); return plugin; } catch (Exception e) when (Log.Logger.LogAndHandle(e)) { - // Include the assembly path since LogAndHandle only reports the caller member - Log.Error("Failed to load plugin : {AssemblyPath}", fullPath); + // Log the resolved path (falls back to the file name if FullName threw) since LogAndHandle + // only reports the caller member + Log.Error("Failed to load plugin : {AssemblyPath}", assemblyPath); return null; } } diff --git a/PlexCleaner/Program.cs b/PlexCleaner/Program.cs index e745f40b..e7fcef36 100644 --- a/PlexCleaner/Program.cs +++ b/PlexCleaner/Program.cs @@ -285,7 +285,13 @@ public static int CustomCommand() return MakeExitCode(ExitCode.Error); } - // Load the plugin after config and tools are initialized + // Load the plugin after config and tools are initialized (the option is required, so the + // path is always present here) + if (Options.PluginAssembly == null) + { + Log.Error("Plugin assembly path is required"); + return MakeExitCode(ExitCode.Error); + } IProcessPlugin? plugin = PluginLoader.Load(Options.PluginAssembly); if (plugin == null) { diff --git a/PlexCleanerTests/CommandLineTests.cs b/PlexCleanerTests/CommandLineTests.cs index ea13e91b..108ca85d 100644 --- a/PlexCleanerTests/CommandLineTests.cs +++ b/PlexCleanerTests/CommandLineTests.cs @@ -383,6 +383,45 @@ public void Parse_Commandline_TestMediaInfo(params string[] args) _ = options.ThreadCount.Should().Be(2); } + [Fact] + public void Parse_Commandline_Custom_ExistingPluginAssembly_Binds() + { + // AcceptExistingOnly validates the path at parse time, so use a file that exists + string existing = typeof(CommandLineTests).Assembly.Location; + string[] args = + [ + "custom", + "--settingsfile=settings.json", + $"--pluginassembly={existing}", + "--mediafiles=/data/foo", + ]; + + CommandLineParser parser = new(args); + _ = parser.Result.Errors.Should().BeEmpty(); + _ = parser.Result.CommandResult.Command.Name.Should().Be("custom"); + + CommandLineOptions options = parser.Bind(); + FileInfo? pluginAssembly = options.PluginAssembly; + _ = pluginAssembly.Should().NotBeNull(); + _ = pluginAssembly.FullName.Should().Be(new FileInfo(existing).FullName); + } + + [Fact] + public void Parse_Commandline_Custom_MissingPluginAssembly_Fails() + { + // A non-existent plugin path fails at parse time via AcceptExistingOnly + string[] args = + [ + "custom", + "--settingsfile=settings.json", + "--pluginassembly=/does/not/exist.dll", + "--mediafiles=/data/foo", + ]; + + CommandLineParser parser = new(args); + _ = parser.Result.Errors.Should().NotBeEmpty(); + } + [Theory] [InlineData("--help")] [InlineData("--version")] diff --git a/PlexCleanerTests/PluginLoaderTests.cs b/PlexCleanerTests/PluginLoaderTests.cs index 5a5d17ef..6b1fd3a1 100644 --- a/PlexCleanerTests/PluginLoaderTests.cs +++ b/PlexCleanerTests/PluginLoaderTests.cs @@ -12,7 +12,7 @@ public class PluginLoaderTests [Fact] public void Load_ExamplePlugin_ReturnsInitializedPlugin() { - IProcessPlugin? plugin = PluginLoader.Load(ExamplePluginPath); + IProcessPlugin? plugin = PluginLoader.Load(new FileInfo(ExamplePluginPath)); // A non-null typed return proves the loaded type satisfies the host's IProcessPlugin // (single shared type identity) and that Initialize accepted the host @@ -26,7 +26,7 @@ public void Load_ExamplePlugin_ReturnsInitializedPlugin() public void Load_MissingAssembly_ReturnsNull() { IProcessPlugin? plugin = PluginLoader.Load( - Path.Combine(AppContext.BaseDirectory, "DoesNotExist.dll") + new FileInfo(Path.Combine(AppContext.BaseDirectory, "DoesNotExist.dll")) ); _ = plugin.Should().BeNull();